utmkit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
utmkit-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 calasdan-sketch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
utmkit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: utmkit
3
+ Version: 0.1.0
4
+ Summary: Batch UTM campaign links with consistent slugs, validation, QR codes and a static index page
5
+ Author: calasdan-sketch
6
+ License-Expression: MIT
7
+ Keywords: utm,campaign,tracking,qr,marketing,csv,links
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Environment :: Console
10
+ Classifier: Topic :: Internet :: WWW/HTTP
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Provides-Extra: qr
15
+ Requires-Dist: qrcode[pil]>=7; extra == "qr"
16
+ Dynamic: license-file
17
+
18
+ # utmkit
19
+
20
+ **Campaign links, done consistently.** Batch-build UTM-tagged URLs from a CSV spec, with deterministic file slugs, validation that catches the mistakes that silently wreck analytics, optional QR codes, and a static index page you can host or print.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install utmkit # links, slugs, validation
26
+ pip install 'utmkit[qr]' # + QR code generation
27
+ ```
28
+
29
+ ## One link
30
+
31
+ ```bash
32
+ utmkit gen https://calasautomations.com/onfile \
33
+ --source instagram --medium social --campaign launch26
34
+ # https://calasautomations.com/onfile?utm_source=instagram&utm_medium=social&utm_campaign=launch26
35
+
36
+ utmkit gen ... --qr launch26-ig.png # QR for the same URL (needs [qr] extra)
37
+ utmkit gen ... --qr launch26-ig.svg # vector SVG for print: needs only the qrcode package, no Pillow
38
+ ```
39
+
40
+ ## Batch
41
+
42
+ `spec.csv`:
43
+
44
+ ```csv
45
+ url,source,medium,campaign,name
46
+ https://calasautomations.com/onfile,instagram,social,launch26,Spring Ad
47
+ https://calasautomations.com/quote,newsletter,email,launch26,Quote Nudge
48
+ ```
49
+
50
+ ```bash
51
+ utmkit batch spec.csv --out-dir out/ --qr --html
52
+ ```
53
+
54
+ Produces `out/links.csv` (slug, final URL, QR filename), one QR code per row when enabled (PNG, or vector SVG with `--qr-format svg`), and `out/index.html` — a table of every link with clickable URLs and QR images.
55
+
56
+ ## Validation
57
+
58
+ `utmkit validate spec.csv` flags the classics without building anything:
59
+
60
+ - **error** — missing `utm_source`, empty URL
61
+ - **warn** (`batch` only) — `duplicate_url`: two rows build the exact same final URL, so their clicks can't be told apart in analytics; the warning names both rows
62
+ - **warn** — uppercase values (analytics tools split `Instagram` from `instagram`), spaces in values, unusual `utm_medium` values, URLs without a scheme
63
+
64
+ `--fix` on `gen` and `batch` cleans the five UTM values before validation: lowercase, trim, and inner spaces to `-` (`" Instagram Ads "` becomes `instagram-ads`). Each change is reported as an `info` finding naming the old and new value. Without `--fix`, nothing is changed.
65
+
66
+ Warnings don't fail the build unless `--strict`. In `batch`, a row with an error is reported and skipped (no link built) and the run exits 1.
67
+
68
+ ## Audit an existing link
69
+
70
+ ```bash
71
+ utmkit parse "https://x.com/?utm_source=IG&utm_medium=social"
72
+ # url: https://x.com/
73
+ # utm_source: IG
74
+ # utm_medium: social
75
+ # WARN not_lowercase: utm_source='IG' should be lowercase (analytics tools split cases)
76
+ # WARN no_campaign: utm_campaign is missing
77
+ ```
78
+
79
+ Splits a tagged URL someone else made into its base URL and UTM values, then runs the same checks as `validate` (plus `--rules` if given). `parse` also warns when `utm_campaign` is missing (`gen`/`batch` don't). Exit 1 on an error such as a missing `utm_source`; `--json` for automation.
80
+
81
+ ## Naming rules (allow-list)
82
+
83
+ Lock the values your team may use, so nobody invents `Insta` next to `instagram`:
84
+
85
+ ```json
86
+ {"source": ["instagram", "facebook"], "medium": ["social", "email", "cpc", "qr"], "campaign_pattern": "^[a-z0-9-]+$"}
87
+ ```
88
+
89
+ ```bash
90
+ utmkit validate spec.csv --rules rules.json # also on gen and batch
91
+ ```
92
+
93
+ All three keys are optional. A `source` or `medium` outside its list, or a `campaign` that doesn't match the regex, is an **error** (`not_allowed`): the row is skipped in `batch` and the run exits 1. When you give a `medium` list, it replaces the built-in "unusual medium" warning. A missing or malformed rules file (bad JSON, a list that isn't a list of strings, an invalid regex, an unknown key) exits 2 with a message. Use `--fix` together with `--rules` to lowercase values before they are checked.
94
+
95
+ utmkit builds links; it doesn't track clicks. Pair it with your analytics tool.
96
+
97
+ ## Design notes
98
+
99
+ - **Deterministic slugs**: the `name` column (else the final tagged URL) becomes the filename slug; collisions get `-2`, `-3` suffixes — same input, same output names, every run.
100
+ - **Existing params survive**: `?id=7&ref=nav` stays intact; stale values for the five standard keys (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`) are replaced by the campaign (the campaign is the source of truth); other params such as `utm_id` are kept.
101
+ - **QR is optional**: the core has zero dependencies; `qrcode` is only imported when you pass `--qr` and installs via the `[qr]` extra. PNG needs Pillow (the extra pulls it in); SVG output uses qrcode's own SVG writer and works without Pillow. Custom backends can be injected through the Python API.
102
+ - **Static `index.html`**: host it, print it, or hand it to whoever is running the campaign — no JS, no build step.
103
+
104
+ ## Python API
105
+
106
+ ```python
107
+ from utmkit import Campaign, build_url, slugify
108
+
109
+ url = build_url(Campaign("https://ex.com", source="ig", medium="social", campaign="c1"))
110
+ assert slugify("Spring Sale — 20% OFF!") == "spring-sale-20-off"
111
+ ```
112
+
113
+ ## License
114
+
115
+ MIT-style: use it, ship it, no warranty.
utmkit-0.1.0/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # utmkit
2
+
3
+ **Campaign links, done consistently.** Batch-build UTM-tagged URLs from a CSV spec, with deterministic file slugs, validation that catches the mistakes that silently wreck analytics, optional QR codes, and a static index page you can host or print.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install utmkit # links, slugs, validation
9
+ pip install 'utmkit[qr]' # + QR code generation
10
+ ```
11
+
12
+ ## One link
13
+
14
+ ```bash
15
+ utmkit gen https://calasautomations.com/onfile \
16
+ --source instagram --medium social --campaign launch26
17
+ # https://calasautomations.com/onfile?utm_source=instagram&utm_medium=social&utm_campaign=launch26
18
+
19
+ utmkit gen ... --qr launch26-ig.png # QR for the same URL (needs [qr] extra)
20
+ utmkit gen ... --qr launch26-ig.svg # vector SVG for print: needs only the qrcode package, no Pillow
21
+ ```
22
+
23
+ ## Batch
24
+
25
+ `spec.csv`:
26
+
27
+ ```csv
28
+ url,source,medium,campaign,name
29
+ https://calasautomations.com/onfile,instagram,social,launch26,Spring Ad
30
+ https://calasautomations.com/quote,newsletter,email,launch26,Quote Nudge
31
+ ```
32
+
33
+ ```bash
34
+ utmkit batch spec.csv --out-dir out/ --qr --html
35
+ ```
36
+
37
+ Produces `out/links.csv` (slug, final URL, QR filename), one QR code per row when enabled (PNG, or vector SVG with `--qr-format svg`), and `out/index.html` — a table of every link with clickable URLs and QR images.
38
+
39
+ ## Validation
40
+
41
+ `utmkit validate spec.csv` flags the classics without building anything:
42
+
43
+ - **error** — missing `utm_source`, empty URL
44
+ - **warn** (`batch` only) — `duplicate_url`: two rows build the exact same final URL, so their clicks can't be told apart in analytics; the warning names both rows
45
+ - **warn** — uppercase values (analytics tools split `Instagram` from `instagram`), spaces in values, unusual `utm_medium` values, URLs without a scheme
46
+
47
+ `--fix` on `gen` and `batch` cleans the five UTM values before validation: lowercase, trim, and inner spaces to `-` (`" Instagram Ads "` becomes `instagram-ads`). Each change is reported as an `info` finding naming the old and new value. Without `--fix`, nothing is changed.
48
+
49
+ Warnings don't fail the build unless `--strict`. In `batch`, a row with an error is reported and skipped (no link built) and the run exits 1.
50
+
51
+ ## Audit an existing link
52
+
53
+ ```bash
54
+ utmkit parse "https://x.com/?utm_source=IG&utm_medium=social"
55
+ # url: https://x.com/
56
+ # utm_source: IG
57
+ # utm_medium: social
58
+ # WARN not_lowercase: utm_source='IG' should be lowercase (analytics tools split cases)
59
+ # WARN no_campaign: utm_campaign is missing
60
+ ```
61
+
62
+ Splits a tagged URL someone else made into its base URL and UTM values, then runs the same checks as `validate` (plus `--rules` if given). `parse` also warns when `utm_campaign` is missing (`gen`/`batch` don't). Exit 1 on an error such as a missing `utm_source`; `--json` for automation.
63
+
64
+ ## Naming rules (allow-list)
65
+
66
+ Lock the values your team may use, so nobody invents `Insta` next to `instagram`:
67
+
68
+ ```json
69
+ {"source": ["instagram", "facebook"], "medium": ["social", "email", "cpc", "qr"], "campaign_pattern": "^[a-z0-9-]+$"}
70
+ ```
71
+
72
+ ```bash
73
+ utmkit validate spec.csv --rules rules.json # also on gen and batch
74
+ ```
75
+
76
+ All three keys are optional. A `source` or `medium` outside its list, or a `campaign` that doesn't match the regex, is an **error** (`not_allowed`): the row is skipped in `batch` and the run exits 1. When you give a `medium` list, it replaces the built-in "unusual medium" warning. A missing or malformed rules file (bad JSON, a list that isn't a list of strings, an invalid regex, an unknown key) exits 2 with a message. Use `--fix` together with `--rules` to lowercase values before they are checked.
77
+
78
+ utmkit builds links; it doesn't track clicks. Pair it with your analytics tool.
79
+
80
+ ## Design notes
81
+
82
+ - **Deterministic slugs**: the `name` column (else the final tagged URL) becomes the filename slug; collisions get `-2`, `-3` suffixes — same input, same output names, every run.
83
+ - **Existing params survive**: `?id=7&ref=nav` stays intact; stale values for the five standard keys (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`) are replaced by the campaign (the campaign is the source of truth); other params such as `utm_id` are kept.
84
+ - **QR is optional**: the core has zero dependencies; `qrcode` is only imported when you pass `--qr` and installs via the `[qr]` extra. PNG needs Pillow (the extra pulls it in); SVG output uses qrcode's own SVG writer and works without Pillow. Custom backends can be injected through the Python API.
85
+ - **Static `index.html`**: host it, print it, or hand it to whoever is running the campaign — no JS, no build step.
86
+
87
+ ## Python API
88
+
89
+ ```python
90
+ from utmkit import Campaign, build_url, slugify
91
+
92
+ url = build_url(Campaign("https://ex.com", source="ig", medium="social", campaign="c1"))
93
+ assert slugify("Spring Sale — 20% OFF!") == "spring-sale-20-off"
94
+ ```
95
+
96
+ ## License
97
+
98
+ MIT-style: use it, ship it, no warranty.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "utmkit"
7
+ version = "0.1.0"
8
+ description = "Batch UTM campaign links with consistent slugs, validation, QR codes and a static index page"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "calasdan-sketch" }]
13
+ keywords = ["utm", "campaign", "tracking", "qr", "marketing", "csv", "links"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Environment :: Console",
17
+ "Topic :: Internet :: WWW/HTTP",
18
+ ]
19
+
20
+ [project.scripts]
21
+ utmkit = "utmkit.cli:main"
22
+
23
+ [project.optional-dependencies]
24
+ qr = ["qrcode[pil]>=7"] # qrcode 8 no longer pulls in an image backend by itself
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
utmkit-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """utmkit — batch UTM campaign links, slugs, validation and QR codes."""
2
+
3
+ from .core import Campaign, Finding, build_url, slugify, validate_campaign
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["Campaign", "Finding", "build_url", "slugify", "validate_campaign", "__version__"]
@@ -0,0 +1,121 @@
1
+ """Batch processing: campaign spec CSV -> tracked links, slugs, optional QR + HTML."""
2
+ from __future__ import annotations
3
+
4
+ import csv
5
+ import html
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from typing import List, Optional
9
+
10
+ from .core import Campaign, Finding, build_url, normalize, slugify, unique_slug, validate_campaign
11
+ from .qr import Backend, QrUnavailable, get_backend
12
+
13
+ COLUMNS = ("url", "source", "medium", "campaign", "term", "content", "name")
14
+
15
+
16
+ def load_campaigns(path: str) -> List[tuple]:
17
+ """Read the spec CSV; returns [(row_number, Campaign)]."""
18
+ out = []
19
+ with open(path, newline="", encoding="utf-8-sig") as fh:
20
+ reader = csv.DictReader(fh)
21
+ if not reader.fieldnames or "url" not in {f.strip().lower() for f in reader.fieldnames}:
22
+ raise ValueError("spec CSV needs a 'url' column")
23
+ for i, row in enumerate(reader, start=2):
24
+ norm = {(k or "").strip().lower(): (v or "").strip() for k, v in row.items()}
25
+ out.append((i, Campaign(
26
+ url=norm.get("url", ""),
27
+ source=norm.get("source", ""),
28
+ medium=norm.get("medium", ""),
29
+ campaign=norm.get("campaign", ""),
30
+ term=norm.get("term", ""),
31
+ content=norm.get("content", ""),
32
+ name=norm.get("name", ""),
33
+ )))
34
+ return out
35
+
36
+
37
+ @dataclass
38
+ class BatchResult:
39
+ rows: List[dict] = field(default_factory=list)
40
+ findings: List[Finding] = field(default_factory=list)
41
+ qr_written: List[str] = field(default_factory=list)
42
+
43
+
44
+ def run_batch(
45
+ campaigns: List[tuple],
46
+ out_dir: str,
47
+ qr: bool = False,
48
+ html_index: bool = False,
49
+ qr_backend: Optional[Backend] = None,
50
+ fix: bool = False,
51
+ rules: Optional[dict] = None,
52
+ qr_format: str = "png",
53
+ ) -> BatchResult:
54
+ result = BatchResult()
55
+ os.makedirs(out_dir, exist_ok=True)
56
+ backend = None
57
+ if qr:
58
+ try:
59
+ backend = get_backend(qr_backend)
60
+ except QrUnavailable as e:
61
+ result.findings.append(Finding("warn", "qr_unavailable", "", str(e)))
62
+ taken: List[str] = []
63
+ seen: dict = {} # final_url -> first row number
64
+ for row_no, campaign in campaigns:
65
+ where = f"row {row_no}"
66
+ if fix:
67
+ campaign, fixed = normalize(campaign, where=where)
68
+ result.findings.extend(fixed)
69
+ row_findings = validate_campaign(campaign, where=where, rules=rules)
70
+ result.findings.extend(row_findings)
71
+ if any(f.severity == "error" for f in row_findings):
72
+ continue # reported above; don't build a broken link (or crash on no url)
73
+ url = build_url(campaign)
74
+ if url in seen: # same tracked link twice: clicks can't be told apart
75
+ result.findings.append(Finding("warn", "duplicate_url", where,
76
+ f"same final URL as row {seen[url]}: {url}"))
77
+ else:
78
+ seen[url] = row_no
79
+ base = slugify(campaign.name or url)
80
+ slug = unique_slug(base, taken)
81
+ taken.append(slug)
82
+ entry = {"slug": slug, "url": campaign.url, "final_url": url, "qr": ""}
83
+ if backend is not None:
84
+ qr_path = os.path.join(out_dir, f"{slug}.{qr_format}")
85
+ try:
86
+ backend(url, qr_path)
87
+ entry["qr"] = os.path.basename(qr_path)
88
+ result.qr_written.append(qr_path)
89
+ except Exception as e: # backend failure shouldn't kill the batch
90
+ result.findings.append(Finding("warn", "qr_failed", where, str(e)))
91
+ result.rows.append(entry)
92
+ if html_index:
93
+ write_html_index(result, os.path.join(out_dir, "index.html"))
94
+ return result
95
+
96
+
97
+ def write_csv(result: BatchResult, path: str) -> None:
98
+ with open(path, "w", newline="", encoding="utf-8") as fh:
99
+ writer = csv.DictWriter(fh, fieldnames=["slug", "final_url", "qr"])
100
+ writer.writeheader()
101
+ for row in result.rows:
102
+ writer.writerow({k: row[k] for k in ("slug", "final_url", "qr")})
103
+
104
+
105
+ def write_html_index(result: BatchResult, path: str) -> None:
106
+ lines = ["<!doctype html><meta charset='utf-8'><title>Campaign links</title>",
107
+ "<table border='1' cellpadding='6'><tr><th>slug</th><th>link</th><th>QR</th></tr>"]
108
+ for row in result.rows:
109
+ qr_cell = (f"<img src='{html.escape(row['qr'])}' width='120' alt='QR'>"
110
+ if row["qr"] else "")
111
+ link = html.escape(row["final_url"])
112
+ if row["final_url"].lower().startswith(("http://", "https://")): # no javascript: hrefs
113
+ link = f"<a href='{link}'>{link}</a>"
114
+ lines.append(
115
+ f"<tr><td>{html.escape(row['slug'])}</td>"
116
+ f"<td>{link}</td>"
117
+ f"<td>{qr_cell}</td></tr>"
118
+ )
119
+ lines.append("</table>")
120
+ with open(path, "w", encoding="utf-8") as fh:
121
+ fh.write("\n".join(lines))
@@ -0,0 +1,130 @@
1
+ """utmkit CLI: build single links or batches from a spec CSV."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .batch import load_campaigns, run_batch, write_csv
10
+ from .core import (Campaign, Finding, build_url, campaign_from_url, load_rules, normalize,
11
+ validate_campaign)
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ for stream in (sys.stdout, sys.stderr): # Windows cp1252 consoles choke on non-Latin values
16
+ if hasattr(stream, "reconfigure"):
17
+ stream.reconfigure(encoding="utf-8")
18
+ ap = argparse.ArgumentParser(prog="utmkit", description="Batch UTM campaign links + QR codes.")
19
+ ap.add_argument("--version", action="version", version=f"utmkit {__version__}")
20
+ sub = ap.add_subparsers(dest="cmd", required=True)
21
+
22
+ g = sub.add_parser("gen", help="build one tracked URL")
23
+ g.add_argument("url")
24
+ g.add_argument("--source", default="")
25
+ g.add_argument("--medium", default="")
26
+ g.add_argument("--campaign", default="")
27
+ g.add_argument("--term", default="")
28
+ g.add_argument("--content", default="")
29
+ g.add_argument("--qr", metavar="PATH", help="also write a QR code here (.svg = vector, else PNG)")
30
+ g.add_argument("--fix", action="store_true", help="lowercase, trim, spaces to '-' in UTM values")
31
+ g.add_argument("--rules", metavar="JSON", help="JSON allow-list: source/medium lists, campaign_pattern regex")
32
+
33
+ b = sub.add_parser("batch", help="build many tracked URLs from a CSV spec")
34
+ b.add_argument("spec", help="CSV with columns: url, source, medium, campaign[, term, content, name]")
35
+ b.add_argument("--out-dir", default="utmkit-out")
36
+ b.add_argument("--qr", action="store_true", help="write QR codes (needs utmkit[qr])")
37
+ b.add_argument("--qr-format", choices=["png", "svg"], default="png",
38
+ help="svg is vector/print-ready and needs no Pillow")
39
+ b.add_argument("--html", action="store_true", help="also write index.html linking everything")
40
+ b.add_argument("--strict", action="store_true", help="validation warnings exit non-zero")
41
+ b.add_argument("--fix", action="store_true", help="lowercase, trim, spaces to '-' in UTM values")
42
+ b.add_argument("--rules", metavar="JSON", help="JSON allow-list: source/medium lists, campaign_pattern regex")
43
+
44
+ v = sub.add_parser("validate", help="check a spec CSV without building anything")
45
+ v.add_argument("spec")
46
+ v.add_argument("--strict", action="store_true")
47
+ v.add_argument("--rules", metavar="JSON", help="JSON allow-list: source/medium lists, campaign_pattern regex")
48
+
49
+ pa = sub.add_parser("parse", help="decode and check an existing tagged URL")
50
+ pa.add_argument("url")
51
+ pa.add_argument("--json", action="store_true")
52
+ pa.add_argument("--rules", metavar="JSON", help="JSON allow-list: source/medium lists, campaign_pattern regex")
53
+
54
+ args = ap.parse_args(argv)
55
+ try:
56
+ rules = load_rules(args.rules) if args.rules else None
57
+ except ValueError as e:
58
+ print(f"utmkit: {e}", file=sys.stderr)
59
+ return 2
60
+
61
+ if args.cmd == "parse":
62
+ campaign = campaign_from_url(args.url)
63
+ findings = validate_campaign(campaign, where="parse", rules=rules)
64
+ if campaign.url and not campaign.campaign: # auditing someone else's link: say it's missing
65
+ findings.append(Finding("warn", "no_campaign", "parse", "utm_campaign is missing"))
66
+ if args.json:
67
+ print(json.dumps({"url": campaign.url, "utm": campaign.utm(),
68
+ "findings": [vars(f) for f in findings]}, indent=2))
69
+ else:
70
+ print(f"url: {campaign.url}")
71
+ for key, value in campaign.utm().items():
72
+ print(f"{key}: {value}")
73
+ for f in findings:
74
+ print(f"{f.severity.upper():5} {f.code}: {f.detail}")
75
+ return 1 if any(f.severity == "error" for f in findings) else 0
76
+
77
+ if args.cmd == "gen":
78
+ campaign = Campaign(url=args.url, source=args.source, medium=args.medium,
79
+ campaign=args.campaign, term=args.term, content=args.content)
80
+ fixed = []
81
+ if args.fix:
82
+ campaign, fixed = normalize(campaign, where="gen")
83
+ findings = fixed + validate_campaign(campaign, where="gen", rules=rules)
84
+ errors = [f for f in findings if f.severity == "error"]
85
+ for f in findings:
86
+ print(f"{f.severity.upper():5} {f.code}: {f.detail}", file=sys.stderr)
87
+ if errors:
88
+ return 1
89
+ url = build_url(campaign)
90
+ print(url)
91
+ if args.qr:
92
+ from .qr import QrUnavailable, get_backend
93
+ try:
94
+ get_backend()(url, args.qr)
95
+ print(f"QR written: {args.qr}", file=sys.stderr)
96
+ except QrUnavailable as e:
97
+ print(f"{e}", file=sys.stderr)
98
+ return 1
99
+ return 0
100
+
101
+ try:
102
+ campaigns = load_campaigns(args.spec)
103
+ except (FileNotFoundError, ValueError) as e:
104
+ print(f"utmkit: {e}", file=sys.stderr)
105
+ return 2
106
+
107
+ if args.cmd == "validate":
108
+ findings = [f for row, c in campaigns for f in validate_campaign(c, where=f"row {row}", rules=rules)]
109
+ for f in findings:
110
+ print(f"{f.severity.upper():5} {f.where}: {f.code}: {f.detail}")
111
+ print(f"{len(findings)} finding(s)")
112
+ return 1 if any(f.severity == "error" for f in findings) or (args.strict and findings) else 0
113
+
114
+ result = run_batch(campaigns, out_dir=args.out_dir, qr=args.qr, html_index=args.html,
115
+ fix=args.fix, rules=rules, qr_format=args.qr_format)
116
+ out_csv = f"{args.out_dir}/links.csv"
117
+ write_csv(result, out_csv)
118
+ errors = [f for f in result.findings if f.severity == "error"]
119
+ warns = [f for f in result.findings if f.severity == "warn"]
120
+ for f in result.findings:
121
+ print(f"{f.severity.upper():5} {f.where}: {f.code}: {f.detail}", file=sys.stderr)
122
+ print(f"{len(result.rows)} link(s) -> {out_csv}"
123
+ + (f", {len(result.qr_written)} QR code(s)" if result.qr_written else ""))
124
+ if errors or (args.strict and warns):
125
+ return 1
126
+ return 0
127
+
128
+
129
+ if __name__ == "__main__": # pragma: no cover
130
+ sys.exit(main())
@@ -0,0 +1,183 @@
1
+ """UTM link building, deterministic slugs and campaign validation."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import unicodedata
7
+ from dataclasses import dataclass, field, replace
8
+ from typing import Dict, List, Optional, Tuple
9
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
10
+
11
+ UTM_KEYS = ("utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content")
12
+ _SLUG_CLEAN = re.compile(r"[^a-z0-9]+")
13
+
14
+
15
+ @dataclass
16
+ class Campaign:
17
+ url: str
18
+ source: str = ""
19
+ medium: str = ""
20
+ campaign: str = ""
21
+ term: str = ""
22
+ content: str = ""
23
+ name: str = ""
24
+ extra: Dict[str, str] = field(default_factory=dict)
25
+
26
+ def utm(self) -> Dict[str, str]:
27
+ pairs = {
28
+ "utm_source": self.source,
29
+ "utm_medium": self.medium,
30
+ "utm_campaign": self.campaign,
31
+ "utm_term": self.term,
32
+ "utm_content": self.content,
33
+ }
34
+ return {k: v for k, v in pairs.items() if v}
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Finding:
39
+ severity: str # error | warn | info
40
+ code: str
41
+ where: str
42
+ detail: str
43
+
44
+
45
+ _UTM_FIELDS = (("utm_source", "source"), ("utm_medium", "medium"), ("utm_campaign", "campaign"),
46
+ ("utm_term", "term"), ("utm_content", "content"))
47
+
48
+
49
+ def normalize(c: Campaign, where: str = "") -> Tuple[Campaign, List[Finding]]:
50
+ """Lowercase, trim and turn inner whitespace into '-' in the five UTM values.
51
+
52
+ Returns a new Campaign plus one ``info`` finding per value that changed.
53
+ """
54
+ where = where or c.name or c.url
55
+ changes, findings = {}, []
56
+ for key, attr in _UTM_FIELDS:
57
+ old = getattr(c, attr)
58
+ new = re.sub(r"\s+", "-", old.strip().lower())
59
+ if new != old:
60
+ changes[attr] = new
61
+ findings.append(Finding("info", "fixed", where, f"{key}: {old!r} -> {new!r}"))
62
+ return replace(c, **changes), findings
63
+
64
+
65
+ def build_url(campaign: Campaign) -> str:
66
+ """Merge UTM parameters into the URL, preserving existing query params.
67
+
68
+ Existing values for the five standard UTM keys are replaced by the campaign
69
+ (the campaign is the source of truth); every other param survives, including
70
+ non-standard ones such as GA4's utm_id.
71
+ """
72
+ if not campaign.url:
73
+ raise ValueError("campaign url is empty")
74
+ parts = urlsplit(campaign.url)
75
+ kept = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
76
+ if k.lower() not in UTM_KEYS]
77
+ merged = kept + list(campaign.utm().items()) + list(campaign.extra.items())
78
+ return urlunsplit((parts.scheme, parts.netloc, parts.path,
79
+ urlencode(merged), parts.fragment))
80
+
81
+
82
+ def slugify(text: str, fallback: str = "link") -> str:
83
+ """URL-safe, lowercase, deterministic slug."""
84
+ text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
85
+ slug = _SLUG_CLEAN.sub("-", text.lower()).strip("-")
86
+ return slug or fallback
87
+
88
+
89
+ def unique_slug(base: str, taken: List[str]) -> str:
90
+ """first-wins with -2, -3 ... suffixes for collisions."""
91
+ if base not in taken:
92
+ return base
93
+ n = 2
94
+ while f"{base}-{n}" in taken:
95
+ n += 1
96
+ return f"{base}-{n}"
97
+
98
+
99
+ def load_rules(path: str) -> dict:
100
+ """Read a rules JSON: optional "source"/"medium" allow-lists and a "campaign_pattern" regex.
101
+
102
+ Raises ValueError with a readable message for a missing file, bad JSON or wrong shapes.
103
+ """
104
+ try:
105
+ with open(path, encoding="utf-8-sig") as fh:
106
+ rules = json.load(fh)
107
+ except FileNotFoundError:
108
+ raise ValueError(f"rules file not found: {path}") from None
109
+ except json.JSONDecodeError as e:
110
+ raise ValueError(f"rules file {path} is not valid JSON: {e}") from None
111
+ if not isinstance(rules, dict):
112
+ raise ValueError("rules file must hold a JSON object")
113
+ unknown = set(rules) - {"source", "medium", "campaign_pattern"}
114
+ if unknown:
115
+ raise ValueError(f"unknown rules key(s): {', '.join(sorted(unknown))}")
116
+ for key in ("source", "medium"):
117
+ if key in rules and not (isinstance(rules[key], list)
118
+ and all(isinstance(v, str) for v in rules[key])):
119
+ raise ValueError(f"rules '{key}' must be a list of strings")
120
+ if "campaign_pattern" in rules:
121
+ if not isinstance(rules["campaign_pattern"], str):
122
+ raise ValueError("rules 'campaign_pattern' must be a string")
123
+ try:
124
+ re.compile(rules["campaign_pattern"])
125
+ except re.error as e:
126
+ raise ValueError(f"rules 'campaign_pattern' is not a valid regex: {e}") from None
127
+ return rules
128
+
129
+
130
+ def validate_campaign(c: Campaign, where: str = "", rules: Optional[dict] = None) -> List[Finding]:
131
+ """Flag the mistakes that silently wreck campaign data.
132
+
133
+ ``rules`` (see load_rules) turn values outside the allow-lists into errors.
134
+ """
135
+ rules = rules or {}
136
+ out: List[Finding] = []
137
+ where = where or c.name or c.url
138
+ if not c.url:
139
+ out.append(Finding("error", "no_url", where, "row has no url"))
140
+ return out
141
+ if not c.source:
142
+ out.append(Finding("error", "no_source", where, "utm_source is required"))
143
+ for key, value in (("utm_source", c.source), ("utm_medium", c.medium),
144
+ ("utm_campaign", c.campaign), ("utm_term", c.term),
145
+ ("utm_content", c.content)):
146
+ if value and value != value.lower():
147
+ out.append(Finding("warn", "not_lowercase", where,
148
+ f"{key}={value!r} should be lowercase (analytics tools split cases)"))
149
+ if " " in value:
150
+ out.append(Finding("warn", "has_spaces", where,
151
+ f"{key}={value!r} has spaces; prefer dashes"))
152
+ for key, value in (("source", c.source), ("medium", c.medium)):
153
+ if value and key in rules and value not in rules[key]:
154
+ out.append(Finding("error", "not_allowed", where,
155
+ f"utm_{key}={value!r} is not in the rules list {rules[key]}"))
156
+ pattern = rules.get("campaign_pattern")
157
+ if c.campaign and pattern and not re.search(pattern, c.campaign):
158
+ out.append(Finding("error", "not_allowed", where,
159
+ f"utm_campaign={c.campaign!r} does not match {pattern!r}"))
160
+ if "medium" not in rules and c.medium and c.medium not in (
161
+ "email", "social", "cpc", "ppc", "affiliate",
162
+ "organic", "referral", "display", "qr", "print"):
163
+ out.append(Finding("warn", "unusual_medium", where,
164
+ f"utm_medium={c.medium!r} is not a common value "
165
+ "(email/social/cpc/...): fine if intentional"))
166
+ if c.url and not c.url.lower().startswith(("http://", "https://")):
167
+ out.append(Finding("warn", "no_scheme", where, f"url {c.url!r} has no http(s) scheme"))
168
+ return out
169
+
170
+
171
+ def campaign_from_url(url: str) -> Campaign:
172
+ """Split a tagged URL back into a Campaign (the inverse of build_url).
173
+
174
+ The five standard UTM keys become fields; every other param stays on ``url``.
175
+ """
176
+ parts = urlsplit(url)
177
+ pairs = parse_qsl(parts.query, keep_blank_values=True)
178
+ utm = {k.lower(): v for k, v in pairs if k.lower() in UTM_KEYS}
179
+ rest = [(k, v) for k, v in pairs if k.lower() not in UTM_KEYS]
180
+ base = urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(rest), parts.fragment))
181
+ return Campaign(url=base, source=utm.get("utm_source", ""), medium=utm.get("utm_medium", ""),
182
+ campaign=utm.get("utm_campaign", ""), term=utm.get("utm_term", ""),
183
+ content=utm.get("utm_content", ""))
@@ -0,0 +1,42 @@
1
+ """QR generation through an optional backend.
2
+
3
+ The core kit never depends on ``qrcode``; QR is enabled when it's installed
4
+ (``pip install utmkit[qr]``) or when a custom backend is injected.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Callable, Optional
9
+
10
+
11
+ class QrUnavailable(RuntimeError):
12
+ """No QR backend present."""
13
+
14
+
15
+ Backend = Callable[[str, str], None] # (data, output_path) -> None
16
+
17
+
18
+ def default_backend() -> Optional[Backend]:
19
+ try:
20
+ import qrcode # type: ignore
21
+ except ImportError:
22
+ return None
23
+
24
+ def render(data: str, out_path: str) -> None:
25
+ if out_path.lower().endswith(".svg"): # vector, print-ready; needs no Pillow
26
+ from qrcode.image.svg import SvgPathImage # type: ignore
27
+ img = qrcode.make(data, image_factory=SvgPathImage)
28
+ else:
29
+ img = qrcode.make(data) # PNG via Pillow (the [qr] extra)
30
+ img.save(out_path)
31
+
32
+ return render
33
+
34
+
35
+ def get_backend(injected: Optional[Backend] = None) -> Backend:
36
+ backend = injected or default_backend()
37
+ if backend is None:
38
+ raise QrUnavailable(
39
+ "QR support needs the 'qrcode' package: pip install 'utmkit[qr]' "
40
+ "(or omit --qr)"
41
+ )
42
+ return backend
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: utmkit
3
+ Version: 0.1.0
4
+ Summary: Batch UTM campaign links with consistent slugs, validation, QR codes and a static index page
5
+ Author: calasdan-sketch
6
+ License-Expression: MIT
7
+ Keywords: utm,campaign,tracking,qr,marketing,csv,links
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Environment :: Console
10
+ Classifier: Topic :: Internet :: WWW/HTTP
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Provides-Extra: qr
15
+ Requires-Dist: qrcode[pil]>=7; extra == "qr"
16
+ Dynamic: license-file
17
+
18
+ # utmkit
19
+
20
+ **Campaign links, done consistently.** Batch-build UTM-tagged URLs from a CSV spec, with deterministic file slugs, validation that catches the mistakes that silently wreck analytics, optional QR codes, and a static index page you can host or print.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install utmkit # links, slugs, validation
26
+ pip install 'utmkit[qr]' # + QR code generation
27
+ ```
28
+
29
+ ## One link
30
+
31
+ ```bash
32
+ utmkit gen https://calasautomations.com/onfile \
33
+ --source instagram --medium social --campaign launch26
34
+ # https://calasautomations.com/onfile?utm_source=instagram&utm_medium=social&utm_campaign=launch26
35
+
36
+ utmkit gen ... --qr launch26-ig.png # QR for the same URL (needs [qr] extra)
37
+ utmkit gen ... --qr launch26-ig.svg # vector SVG for print: needs only the qrcode package, no Pillow
38
+ ```
39
+
40
+ ## Batch
41
+
42
+ `spec.csv`:
43
+
44
+ ```csv
45
+ url,source,medium,campaign,name
46
+ https://calasautomations.com/onfile,instagram,social,launch26,Spring Ad
47
+ https://calasautomations.com/quote,newsletter,email,launch26,Quote Nudge
48
+ ```
49
+
50
+ ```bash
51
+ utmkit batch spec.csv --out-dir out/ --qr --html
52
+ ```
53
+
54
+ Produces `out/links.csv` (slug, final URL, QR filename), one QR code per row when enabled (PNG, or vector SVG with `--qr-format svg`), and `out/index.html` — a table of every link with clickable URLs and QR images.
55
+
56
+ ## Validation
57
+
58
+ `utmkit validate spec.csv` flags the classics without building anything:
59
+
60
+ - **error** — missing `utm_source`, empty URL
61
+ - **warn** (`batch` only) — `duplicate_url`: two rows build the exact same final URL, so their clicks can't be told apart in analytics; the warning names both rows
62
+ - **warn** — uppercase values (analytics tools split `Instagram` from `instagram`), spaces in values, unusual `utm_medium` values, URLs without a scheme
63
+
64
+ `--fix` on `gen` and `batch` cleans the five UTM values before validation: lowercase, trim, and inner spaces to `-` (`" Instagram Ads "` becomes `instagram-ads`). Each change is reported as an `info` finding naming the old and new value. Without `--fix`, nothing is changed.
65
+
66
+ Warnings don't fail the build unless `--strict`. In `batch`, a row with an error is reported and skipped (no link built) and the run exits 1.
67
+
68
+ ## Audit an existing link
69
+
70
+ ```bash
71
+ utmkit parse "https://x.com/?utm_source=IG&utm_medium=social"
72
+ # url: https://x.com/
73
+ # utm_source: IG
74
+ # utm_medium: social
75
+ # WARN not_lowercase: utm_source='IG' should be lowercase (analytics tools split cases)
76
+ # WARN no_campaign: utm_campaign is missing
77
+ ```
78
+
79
+ Splits a tagged URL someone else made into its base URL and UTM values, then runs the same checks as `validate` (plus `--rules` if given). `parse` also warns when `utm_campaign` is missing (`gen`/`batch` don't). Exit 1 on an error such as a missing `utm_source`; `--json` for automation.
80
+
81
+ ## Naming rules (allow-list)
82
+
83
+ Lock the values your team may use, so nobody invents `Insta` next to `instagram`:
84
+
85
+ ```json
86
+ {"source": ["instagram", "facebook"], "medium": ["social", "email", "cpc", "qr"], "campaign_pattern": "^[a-z0-9-]+$"}
87
+ ```
88
+
89
+ ```bash
90
+ utmkit validate spec.csv --rules rules.json # also on gen and batch
91
+ ```
92
+
93
+ All three keys are optional. A `source` or `medium` outside its list, or a `campaign` that doesn't match the regex, is an **error** (`not_allowed`): the row is skipped in `batch` and the run exits 1. When you give a `medium` list, it replaces the built-in "unusual medium" warning. A missing or malformed rules file (bad JSON, a list that isn't a list of strings, an invalid regex, an unknown key) exits 2 with a message. Use `--fix` together with `--rules` to lowercase values before they are checked.
94
+
95
+ utmkit builds links; it doesn't track clicks. Pair it with your analytics tool.
96
+
97
+ ## Design notes
98
+
99
+ - **Deterministic slugs**: the `name` column (else the final tagged URL) becomes the filename slug; collisions get `-2`, `-3` suffixes — same input, same output names, every run.
100
+ - **Existing params survive**: `?id=7&ref=nav` stays intact; stale values for the five standard keys (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`) are replaced by the campaign (the campaign is the source of truth); other params such as `utm_id` are kept.
101
+ - **QR is optional**: the core has zero dependencies; `qrcode` is only imported when you pass `--qr` and installs via the `[qr]` extra. PNG needs Pillow (the extra pulls it in); SVG output uses qrcode's own SVG writer and works without Pillow. Custom backends can be injected through the Python API.
102
+ - **Static `index.html`**: host it, print it, or hand it to whoever is running the campaign — no JS, no build step.
103
+
104
+ ## Python API
105
+
106
+ ```python
107
+ from utmkit import Campaign, build_url, slugify
108
+
109
+ url = build_url(Campaign("https://ex.com", source="ig", medium="social", campaign="c1"))
110
+ assert slugify("Spring Sale — 20% OFF!") == "spring-sale-20-off"
111
+ ```
112
+
113
+ ## License
114
+
115
+ MIT-style: use it, ship it, no warranty.
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/utmkit/__init__.py
5
+ src/utmkit/batch.py
6
+ src/utmkit/cli.py
7
+ src/utmkit/core.py
8
+ src/utmkit/qr.py
9
+ src/utmkit.egg-info/PKG-INFO
10
+ src/utmkit.egg-info/SOURCES.txt
11
+ src/utmkit.egg-info/dependency_links.txt
12
+ src/utmkit.egg-info/entry_points.txt
13
+ src/utmkit.egg-info/requires.txt
14
+ src/utmkit.egg-info/top_level.txt
15
+ tests/test_batch.py
16
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ utmkit = utmkit.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [qr]
3
+ qrcode[pil]>=7
@@ -0,0 +1 @@
1
+ utmkit
@@ -0,0 +1,199 @@
1
+ """Batch, QR-backend and CLI tests."""
2
+ import csv
3
+ import os
4
+
5
+ from utmkit.batch import load_campaigns, run_batch, write_csv
6
+ from utmkit.cli import main
7
+
8
+
9
+ def write_spec(tmp, rows):
10
+ p = tmp / "spec.csv"
11
+ with open(p, "w", newline="", encoding="utf-8") as fh:
12
+ w = csv.writer(fh)
13
+ w.writerow(["url", "source", "medium", "campaign", "name"])
14
+ for r in rows:
15
+ w.writerow(r)
16
+ return str(p)
17
+
18
+
19
+ def test_load_and_batch(tmp_path):
20
+ spec = write_spec(tmp_path, [
21
+ ["https://ex.com/a", "instagram", "social", "launch", "Spring Ad"],
22
+ ["https://ex.com/b", "newsletter", "email", "launch", "Spring Ad"],
23
+ ])
24
+ campaigns = load_campaigns(spec)
25
+ assert len(campaigns) == 2 and campaigns[0][1].source == "instagram"
26
+ result = run_batch(campaigns, str(tmp_path / "out"))
27
+ assert len(result.rows) == 2
28
+ # identical names get unique slugs
29
+ slugs = [r["slug"] for r in result.rows]
30
+ assert slugs == ["spring-ad", "spring-ad-2"]
31
+ assert result.rows[0]["final_url"].startswith("https://ex.com/a?utm_source=instagram")
32
+
33
+
34
+ def test_qr_with_fake_backend(tmp_path):
35
+ calls = []
36
+
37
+ def fake_backend(data, path):
38
+ calls.append((data, path))
39
+ with open(path, "wb") as fh:
40
+ fh.write(b"\x89PNG")
41
+
42
+ spec = write_spec(tmp_path, [["https://ex.com", "ig", "social", "c", "n"]])
43
+ result = run_batch(load_campaigns(spec), str(tmp_path / "out"), qr=True, qr_backend=fake_backend)
44
+ assert len(calls) == 1 and result.rows[0]["qr"] == "n.png"
45
+ assert os.path.exists(os.path.join(str(tmp_path / "out"), "n.png"))
46
+
47
+
48
+ def test_qr_unavailable_warns_not_crashes(tmp_path):
49
+ spec = write_spec(tmp_path, [["https://ex.com", "ig", "social", "c", "n"]])
50
+ result = run_batch(load_campaigns(spec), str(tmp_path / "out"), qr=True, qr_backend=None)
51
+ assert any(f.code == "qr_unavailable" for f in result.findings) or result.qr_written
52
+
53
+
54
+ def test_html_index(tmp_path):
55
+ spec = write_spec(tmp_path, [["https://ex.com", "ig", "social", "c", "Home"]])
56
+ run_batch(load_campaigns(spec), str(tmp_path / "out"), html_index=True)
57
+ html = (tmp_path / "out" / "index.html").read_text(encoding="utf-8")
58
+ assert "utm_source=ig" in html and "<table" in html
59
+
60
+
61
+ def test_write_csv(tmp_path):
62
+ spec = write_spec(tmp_path, [["https://ex.com", "ig", "social", "c", "x"]])
63
+ result = run_batch(load_campaigns(spec), str(tmp_path / "out"))
64
+ out = tmp_path / "out" / "links.csv"
65
+ write_csv(result, str(out))
66
+ rows = list(csv.DictReader(open(out, encoding="utf-8")))
67
+ assert rows[0]["slug"] == "x" and rows[0]["final_url"].startswith("https://ex.com?")
68
+
69
+
70
+ def test_cli_gen(capsys):
71
+ assert main(["gen", "https://ex.com", "--source", "ig", "--medium", "social",
72
+ "--campaign", "launch"]) == 0
73
+ assert "utm_source=ig" in capsys.readouterr().out
74
+
75
+
76
+ def test_cli_gen_requires_source(capsys):
77
+ assert main(["gen", "https://ex.com"]) == 1
78
+
79
+
80
+ def test_cli_batch_and_validate(tmp_path, capsys):
81
+ spec = write_spec(tmp_path, [
82
+ ["https://ex.com/a", "ig", "social", "launch", "One"],
83
+ ["https://ex.com/b", "Newsletter", "email", "launch", "Two"],
84
+ ])
85
+ assert main(["batch", spec, "--out-dir", str(tmp_path / "o"), "--html"]) == 0
86
+ assert (tmp_path / "o" / "links.csv").exists() and (tmp_path / "o" / "index.html").exists()
87
+ capsys.readouterr()
88
+ assert main(["validate", spec]) == 0 # warnings only
89
+ assert main(["validate", spec, "--strict"]) == 1
90
+
91
+
92
+ def test_cli_bad_spec(tmp_path):
93
+ bad = tmp_path / "bad.csv"
94
+ bad.write_text("nope,columns\n1,2\n", encoding="utf-8") # lacks the url column
95
+ assert main(["batch", str(bad)]) == 2
96
+
97
+
98
+ def test_batch_skips_error_rows_instead_of_crashing(tmp_path):
99
+ spec = write_spec(tmp_path, [
100
+ ["", "instagram", "social", "launch", "No URL"],
101
+ ["https://ex.com/a", "instagram", "social", "launch", "Good"],
102
+ ])
103
+ out = tmp_path / "out"
104
+ assert main(["batch", spec, "--out-dir", str(out)]) == 1
105
+ rows = list(csv.DictReader(open(out / "links.csv", encoding="utf-8")))
106
+ assert [r["slug"] for r in rows] == ["good"]
107
+
108
+
109
+ def test_html_index_never_links_non_http_urls(tmp_path):
110
+ spec = write_spec(tmp_path, [["javascript:alert(1)", "x", "email", "c", "bad"]])
111
+ out = tmp_path / "out"
112
+ main(["batch", spec, "--out-dir", str(out), "--html"])
113
+ page = (out / "index.html").read_text(encoding="utf-8")
114
+ assert "href='javascript" not in page and "javascript:alert(1)" in page
115
+
116
+
117
+ def test_fix_flag_on_gen_and_batch(tmp_path, capsys):
118
+ assert main(["gen", "https://ex.com", "--source", " Instagram Ads ", "--fix"]) == 0
119
+ out, err = capsys.readouterr()
120
+ assert "utm_source=instagram-ads" in out and "INFO" in err and "fixed" in err
121
+ assert main(["gen", "https://ex.com", "--source", " Instagram Ads "]) == 0
122
+ out, err = capsys.readouterr()
123
+ assert "Instagram+Ads" in out and "not_lowercase" in err # unchanged without --fix
124
+ spec = write_spec(tmp_path, [["https://ex.com/a", "Instagram Ads", "Social", "launch", "One"]])
125
+ assert main(["batch", spec, "--out-dir", str(tmp_path / "o"), "--fix", "--strict"]) == 0
126
+ rows = list(csv.DictReader(open(tmp_path / "o" / "links.csv", encoding="utf-8")))
127
+ assert "utm_source=instagram-ads&utm_medium=social" in rows[0]["final_url"]
128
+
129
+
130
+ def test_duplicate_final_url_warns(tmp_path, capsys):
131
+ row = ["https://ex.com/a", "ig", "social", "launch", "One"]
132
+ spec = write_spec(tmp_path, [row, ["https://ex.com/b", "ig", "social", "launch", "Two"], row])
133
+ result = run_batch(load_campaigns(spec), str(tmp_path / "o"))
134
+ dups = [f for f in result.findings if f.code == "duplicate_url"]
135
+ assert len(dups) == 1 and dups[0].severity == "warn"
136
+ assert "row 2" in dups[0].detail and dups[0].where == "row 4"
137
+ assert main(["batch", spec, "--out-dir", str(tmp_path / "o")]) == 0
138
+ assert main(["batch", spec, "--out-dir", str(tmp_path / "o"), "--strict"]) == 1
139
+
140
+
141
+ RULES = {"source": ["instagram", "facebook"], "medium": ["social", "email", "cpc", "qr"],
142
+ "campaign_pattern": "^[a-z0-9-]+$"}
143
+
144
+
145
+ def test_rules_file_allow_list(tmp_path, capsys):
146
+ import json
147
+ rules = tmp_path / "rules.json"
148
+ rules.write_text(json.dumps(RULES), encoding="utf-8")
149
+ spec = write_spec(tmp_path, [["https://ex.com/a", "instagram", "Social-Media", "launch", "One"]])
150
+ assert main(["validate", spec, "--rules", str(rules)]) == 1
151
+ assert "not_allowed" in capsys.readouterr().out
152
+ assert main(["batch", spec, "--out-dir", str(tmp_path / "o"), "--rules", str(rules)]) == 1
153
+ assert main(["gen", "https://ex.com", "--source", "instagram", "--medium", "social",
154
+ "--campaign", "Spring Sale", "--rules", str(rules)]) == 1 # campaign pattern
155
+ assert main(["gen", "https://ex.com", "--source", "facebook", "--medium", "cpc",
156
+ "--campaign", "spring-sale", "--rules", str(rules)]) == 0
157
+ rules.write_text("{not json", encoding="utf-8")
158
+ assert main(["validate", spec, "--rules", str(rules)]) == 2
159
+ rules.write_text('{"medium": "social"}', encoding="utf-8") # must be a list
160
+ assert main(["validate", spec, "--rules", str(rules)]) == 2
161
+ rules.write_text('{"campaign_pattern": "("}', encoding="utf-8") # bad regex
162
+ assert main(["validate", spec, "--rules", str(rules)]) == 2
163
+ assert main(["validate", spec, "--rules", str(tmp_path / "missing.json")]) == 2
164
+
165
+
166
+ def test_parse_existing_url(capsys):
167
+ import json
168
+ url = "https://x.com/?utm_source=IG&utm_medium=social"
169
+ assert main(["parse", url]) == 0
170
+ out = capsys.readouterr().out
171
+ assert "utm_source: IG" in out and "not_lowercase" in out and "no_campaign" in out
172
+ assert main(["parse", url, "--json"]) == 0
173
+ data = json.loads(capsys.readouterr().out)
174
+ assert data["url"] == "https://x.com/" and data["utm"]["utm_source"] == "IG"
175
+ codes = {(f["severity"], f["code"]) for f in data["findings"]}
176
+ assert ("warn", "not_lowercase") in codes and ("warn", "no_campaign") in codes
177
+ assert main(["parse", "https://x.com/?id=7"]) == 1 # no utm_source: error
178
+
179
+
180
+ def test_batch_qr_format_svg_names_files_svg(tmp_path):
181
+ paths = []
182
+
183
+ def fake_backend(data, path):
184
+ paths.append(path)
185
+ open(path, "w").close()
186
+
187
+ spec = write_spec(tmp_path, [["https://ex.com", "ig", "social", "c", "n"]])
188
+ result = run_batch(load_campaigns(spec), str(tmp_path / "out"), qr=True,
189
+ qr_backend=fake_backend, qr_format="svg")
190
+ assert paths[0].endswith("n.svg") and result.rows[0]["qr"] == "n.svg"
191
+
192
+
193
+ def test_real_svg_qr_needs_no_pillow(tmp_path, capsys):
194
+ import pytest
195
+ pytest.importorskip("qrcode")
196
+ out = tmp_path / "x.svg"
197
+ assert main(["gen", "https://ex.com", "--source", "ig", "--qr", str(out)]) == 0
198
+ head = out.read_text(encoding="utf-8").lstrip()[:5]
199
+ assert head in ("<?xml", "<svg ") or head.startswith("<svg")
@@ -0,0 +1,95 @@
1
+ """URL building, slug and validation tests."""
2
+ import pytest
3
+
4
+ from utmkit.core import Campaign, build_url, slugify, unique_slug, validate_campaign
5
+
6
+
7
+ def test_build_url_appends_utm():
8
+ url = build_url(Campaign("https://ex.com/landing", source="instagram", medium="social",
9
+ campaign="launch26"))
10
+ assert url == "https://ex.com/landing?utm_source=instagram&utm_medium=social&utm_campaign=launch26"
11
+
12
+
13
+ def test_build_url_preserves_existing_query():
14
+ url = build_url(Campaign("https://ex.com/p?id=7&ref=nav", source="email", campaign="wk42"))
15
+ assert "id=7" in url and "ref=nav" in url and "utm_source=email" in url
16
+
17
+
18
+ def test_build_url_overrides_existing_utm():
19
+ url = build_url(Campaign("https://ex.com/?utm_source=old", source="new"))
20
+ assert "utm_source=new" in url and "old" not in url
21
+
22
+
23
+ def test_build_url_encodes_spaces_and_fragment_kept():
24
+ url = build_url(Campaign("https://ex.com/a#top", source="a b", campaign="c"))
25
+ assert "utm_source=a+b" in url or "utm_source=a%20b" in url
26
+ assert url.endswith("#top")
27
+
28
+
29
+ def test_build_url_empty_rejected():
30
+ with pytest.raises(ValueError):
31
+ build_url(Campaign(""))
32
+
33
+
34
+ def test_slugify():
35
+ assert slugify("Spring Sale — 20% OFF!") == "spring-sale-20-off"
36
+ assert slugify("Ünïcode Ünicorn") == "unicode-unicorn"
37
+ assert slugify("***") == "link"
38
+ assert slugify("") == "link"
39
+
40
+
41
+ def test_unique_slug_suffixes():
42
+ taken = []
43
+ assert unique_slug("a", taken) == "a" or (taken.append("a") or True)
44
+ taken.clear()
45
+ first = unique_slug("a", taken)
46
+ taken.append(first)
47
+ second = unique_slug("a", taken)
48
+ taken.append(second)
49
+ third = unique_slug("a", taken)
50
+ assert (first, second, third) == ("a", "a-2", "a-3")
51
+
52
+
53
+ def test_validate_required_source():
54
+ findings = validate_campaign(Campaign("https://x.com"))
55
+ assert any(f.code == "no_source" and f.severity == "error" for f in findings)
56
+
57
+
58
+ def test_validate_case_and_spaces_warn():
59
+ findings = validate_campaign(Campaign("https://x.com", source="Instagram", medium="email",
60
+ campaign="Spring Sale"))
61
+ codes = [f.code for f in findings]
62
+ assert "not_lowercase" in codes and "has_spaces" in codes
63
+
64
+
65
+ def test_validate_unusual_medium_and_scheme():
66
+ findings = validate_campaign(Campaign("ex.com", source="a", medium="teleport"))
67
+ codes = [f.code for f in findings]
68
+ assert "unusual_medium" in codes and "no_scheme" in codes
69
+
70
+
71
+ def test_validate_known_mediums_quiet():
72
+ for medium in ("email", "social", "cpc", "qr", "print"):
73
+ assert validate_campaign(Campaign("https://x.com", source="a", medium=medium)) == []
74
+
75
+
76
+ def test_non_standard_utm_params_survive():
77
+ url = build_url(Campaign("https://ex.com/?utm_id=42&utm_source=old", source="new"))
78
+ assert "utm_id=42" in url and "utm_source=new" in url and "old" not in url
79
+
80
+
81
+ def test_normalize_fixes_case_space_and_reports_changes():
82
+ from utmkit.core import normalize
83
+ c, findings = normalize(Campaign("https://x.com", source=" Instagram Ads ", medium="social"), where="gen")
84
+ assert c.source == "instagram-ads" and c.medium == "social"
85
+ assert len(findings) == 1 and findings[0].severity == "info" and findings[0].code == "fixed"
86
+ assert "Instagram Ads" in findings[0].detail and "instagram-ads" in findings[0].detail
87
+ assert validate_campaign(c) == []
88
+
89
+
90
+ def test_validate_with_rules():
91
+ rules = {"medium": ["social"], "source": ["ig"]}
92
+ f = validate_campaign(Campaign("https://x.com", source="fb", medium="teleport"), rules=rules)
93
+ codes = [(x.severity, x.code) for x in f]
94
+ assert codes.count(("error", "not_allowed")) == 2
95
+ assert ("warn", "unusual_medium") not in codes # the allow-list replaces the generic list