pyclickplc 0.3.2__tar.gz → 0.3.4__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.
Files changed (76) hide show
  1. pyclickplc-0.3.4/.github/scripts/check_public_site.py +61 -0
  2. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/.github/workflows/ci.yml +3 -3
  3. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/.github/workflows/docs.yml +18 -4
  4. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/.github/workflows/publish.yml +3 -3
  5. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/.gitignore +1 -0
  6. pyclickplc-0.3.2/CLAUDE.md → pyclickplc-0.3.4/AGENTS.md +2 -2
  7. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/CHANGELOG.md +21 -0
  8. pyclickplc-0.3.4/CLAUDE.md +1 -0
  9. pyclickplc-0.3.4/Makefile +83 -0
  10. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/PKG-INFO +2 -2
  11. pyclickplc-0.3.4/devtools/check_docs_site.py +140 -0
  12. pyclickplc-0.3.4/devtools/prepare_docs.py +80 -0
  13. pyclickplc-0.3.2/docs/CLAUDE.md → pyclickplc-0.3.4/docs/AGENTS.md +1 -1
  14. pyclickplc-0.3.4/docs/CLAUDE.md +1 -0
  15. pyclickplc-0.3.4/docs/gen_llms.py +117 -0
  16. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/gen_reference.py +51 -15
  17. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/mkdocs.yml +4 -13
  18. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/pyproject.toml +7 -7
  19. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/__init__.py +18 -0
  20. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/blocks.py +44 -4
  21. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/nicknames.py +13 -4
  22. pyclickplc-0.3.4/src/pyclickplc/system/__init__.py +23 -0
  23. pyclickplc-0.3.4/src/pyclickplc/system/_nicknames.py +438 -0
  24. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_blocks.py +75 -0
  25. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_modbus_service.py +21 -4
  26. pyclickplc-0.3.4/tests/test_system_nicknames.py +81 -0
  27. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/uv.lock +142 -386
  28. pyclickplc-0.3.2/Makefile +0 -62
  29. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/.claude/settings.local.json +0 -0
  30. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/LICENSE +0 -0
  31. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/README.md +0 -0
  32. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/devtools/lint.py +0 -0
  33. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/getting-started/quickstart.md +0 -0
  34. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/addressing.md +0 -0
  35. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/client.md +0 -0
  36. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/examples.md +0 -0
  37. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/files.md +0 -0
  38. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/modbus_service.md +0 -0
  39. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/server.md +0 -0
  40. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/types.md +0 -0
  41. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/index.md +0 -0
  42. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/releases/v0.2.0.md +0 -0
  43. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/releases/v0.3.0.md +0 -0
  44. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/examples/sync_clickplc_datetime.py +0 -0
  45. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/examples/traffic_light.py +0 -0
  46. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/examples/traffic_light_dataview.cdv +0 -0
  47. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/examples/traffic_light_nicknames.csv +0 -0
  48. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/addresses.py +0 -0
  49. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/banks.py +0 -0
  50. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/client.py +0 -0
  51. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/dataview.py +0 -0
  52. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/modbus.py +0 -0
  53. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/modbus_service.py +0 -0
  54. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/plcdata.py +0 -0
  55. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/py.typed +0 -0
  56. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/server.py +0 -0
  57. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/server_tui.py +0 -0
  58. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/validation.py +0 -0
  59. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/__init__.py +0 -0
  60. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/examples/__init__.py +0 -0
  61. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/examples/_traffic_light_loader.py +0 -0
  62. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/examples/test_sync_clickplc_datetime.py +0 -0
  63. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/examples/test_traffic_light_cycle.py +0 -0
  64. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/examples/test_traffic_light_files.py +0 -0
  65. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/examples/test_traffic_light_smoke.py +0 -0
  66. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_addresses.py +0 -0
  67. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_banks.py +0 -0
  68. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_client.py +0 -0
  69. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_dataview.py +0 -0
  70. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_integration.py +0 -0
  71. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_modbus.py +0 -0
  72. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_nicknames.py +0 -0
  73. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_plcdata.py +0 -0
  74. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_server.py +0 -0
  75. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_server_tui.py +0 -0
  76. {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_validation.py +0 -0
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env python3
2
+ """Reject internal or superseded files from the public documentation site."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from pathlib import Path
8
+
9
+ BLOCKED_FILENAMES = {
10
+ "agents.md",
11
+ "claude.md",
12
+ "gen_llms.py",
13
+ "gen_reference.py",
14
+ "llms-full.txt",
15
+ }
16
+
17
+
18
+ def ascii_text(value: object) -> str:
19
+ return str(value).encode("ascii", errors="backslashreplace").decode("ascii")
20
+
21
+
22
+ def blocked_reason(relative_path: Path) -> str | None:
23
+ parts = tuple(part.lower() for part in relative_path.parts)
24
+ if "internal" in parts:
25
+ return "path segment named internal"
26
+ if relative_path.name.lower() in BLOCKED_FILENAMES:
27
+ return f"blocked filename {relative_path.name}"
28
+ return None
29
+
30
+
31
+ def main() -> int:
32
+ parser = argparse.ArgumentParser(description=__doc__)
33
+ parser.add_argument("site", type=Path, help="Generated site directory to inspect")
34
+ args = parser.parse_args()
35
+
36
+ site = args.site
37
+ if not site.is_dir():
38
+ print(f"ERROR: public site directory does not exist: {ascii_text(site)}")
39
+ return 2
40
+
41
+ violations: list[tuple[Path, str]] = []
42
+ for path in site.rglob("*"):
43
+ relative_path = path.relative_to(site)
44
+ if reason := blocked_reason(relative_path):
45
+ violations.append((relative_path, reason))
46
+
47
+ if violations:
48
+ for relative_path, reason in sorted(violations):
49
+ print(
50
+ "ERROR: blocked public-site path: "
51
+ f"{ascii_text(relative_path.as_posix())} ({reason})"
52
+ )
53
+ print(f"ERROR: public-site guard found {len(violations)} blocked path(s).")
54
+ return 1
55
+
56
+ print(f"OK: public-site guard passed: {ascii_text(site)}")
57
+ return 0
58
+
59
+
60
+ if __name__ == "__main__":
61
+ raise SystemExit(main())
@@ -37,11 +37,11 @@ jobs:
37
37
  fetch-depth: 0
38
38
 
39
39
  - name: Install uv (official Astral action)
40
- uses: astral-sh/setup-uv@v7
40
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
41
41
  with:
42
42
  # Update this as needed:
43
- version: "0.9.25"
44
- enable-cache: true
43
+ version: "0.11.29"
44
+ enable-cache: auto
45
45
  python-version: ${{ matrix.python-version }}
46
46
 
47
47
  - name: Set up Python (using uv)
@@ -3,6 +3,8 @@ name: Docs
3
3
  on:
4
4
  push:
5
5
  branches: ["main"]
6
+ pull_request:
7
+ branches: ["main"]
6
8
  workflow_dispatch:
7
9
 
8
10
  permissions:
@@ -22,10 +24,10 @@ jobs:
22
24
  uses: actions/checkout@v6
23
25
 
24
26
  - name: Install uv
25
- uses: astral-sh/setup-uv@v7
27
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
26
28
  with:
27
- version: "0.9.25"
28
- enable-cache: true
29
+ version: "0.11.29"
30
+ enable-cache: auto
29
31
  python-version: "3.12"
30
32
 
31
33
  - name: Set up Python
@@ -35,14 +37,26 @@ jobs:
35
37
  run: uv sync --locked --group docs
36
38
 
37
39
  - name: Build docs
38
- run: uv run mkdocs build --strict
40
+ run: |
41
+ uv run --no-sync python devtools/prepare_docs.py
42
+ uv run --no-sync python docs/gen_reference.py --output-dir docs_build
43
+ uv run --no-sync python docs/gen_llms.py --docs-dir docs_build --output-file docs_build/llms.txt
44
+ uv run --no-sync zensical build --clean --strict
45
+
46
+ - name: Check docs routes
47
+ run: uv run --no-sync python devtools/check_docs_site.py docs_build site
48
+
49
+ - name: Guard public site
50
+ run: uv run --no-sync python .github/scripts/check_public_site.py site
39
51
 
40
52
  - name: Upload Pages artifact
53
+ if: github.event_name != 'pull_request'
41
54
  uses: actions/upload-pages-artifact@v4
42
55
  with:
43
56
  path: site
44
57
 
45
58
  deploy:
59
+ if: github.event_name != 'pull_request'
46
60
  needs: build
47
61
  runs-on: ubuntu-latest
48
62
  environment:
@@ -19,10 +19,10 @@ jobs:
19
19
  fetch-depth: 0
20
20
 
21
21
  - name: Install uv (official Astral action)
22
- uses: astral-sh/setup-uv@v7
22
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
23
23
  with:
24
- version: "0.9.25"
25
- enable-cache: true
24
+ version: "0.11.29"
25
+ enable-cache: auto
26
26
  python-version: "3.12"
27
27
 
28
28
  - name: Set up Python (using uv)
@@ -153,6 +153,7 @@ venv.bak/
153
153
 
154
154
  # mkdocs documentation
155
155
  /site
156
+ /docs_build/
156
157
 
157
158
  # mypy
158
159
  .mypy_cache/
@@ -1,6 +1,6 @@
1
- # CLAUDE.md
1
+ # pyclickplc Agent Guidance
2
2
 
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
3
+ This file provides guidance to coding agents working in this repository.
4
4
 
5
5
  ## Project Overview
6
6
 
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.3.4 - 2026-09-02
6
+
7
+ ### Added
8
+
9
+ - Maintained AutomationDirect guidance for 344 documented SC/SD system nicknames, including conservative canonicalization helpers and source metadata for downstream repair workflows.
10
+
11
+ ## 0.3.3 - 2026-08-25
12
+
13
+ ### Added
14
+
15
+ - `compose_structured_block_name()` for building structured block name strings from components (inverse of `parse_structured_block_name()`).
16
+ - `parse_structured_block_name()` now recognizes `Base.Field:udt` (with explicit `:udt` suffix) and `Base:block` (without start override) in addition to existing patterns.
17
+
18
+ ## 0.3.2 - 2026-04-15
19
+
20
+ ### Changed
21
+
22
+ - Pinned pymodbus to the compatible 3.12 release series and updated client/server integration for its current API.
23
+
3
24
  ## 0.3.1 - 2026-03-29
4
25
 
5
26
  ### Fixed
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,83 @@
1
+ # Makefile for easy development workflows.
2
+ # See development.md for docs.
3
+ # Note GitHub Actions call uv directly, not this Makefile.
4
+
5
+ .DEFAULT_GOAL := default
6
+
7
+ .PHONY: default install lint test upgrade build clean docs-clean docs-generated-clean docs-generate docs-serve docs-build docs-check
8
+
9
+ default: install lint test
10
+
11
+ install:
12
+ uv sync --locked --all-extras --dev
13
+
14
+ lint:
15
+ uv run devtools/lint.py
16
+
17
+ test:
18
+ uv run pytest
19
+
20
+ upgrade:
21
+ uv lock --upgrade
22
+ uv sync --locked --all-extras --dev
23
+
24
+ build:
25
+ uv build
26
+
27
+ # Improved Windows detection
28
+ ifeq ($(OS),Windows_NT)
29
+ WINDOWS := 1
30
+ else
31
+ ifeq ($(shell uname -s),Windows)
32
+ WINDOWS := 1
33
+ else
34
+ WINDOWS := 0
35
+ endif
36
+ endif
37
+
38
+ ifeq ($(WINDOWS),1)
39
+ # Windows commands
40
+ RM = powershell -Command "Remove-Item -Recurse -Force"
41
+ RM_SITE = powershell -Command "if (Test-Path 'site') { Remove-Item -Recurse -Force 'site' }"
42
+ FIND_PYCACHE = powershell -Command "Get-ChildItem -Path . -Filter '__pycache__' -Recurse -Directory | Remove-Item -Recurse -Force"
43
+ RM_GENERATED_DOCS = powershell -Command "if (Test-Path 'docs_build') { Remove-Item -Recurse -Force 'docs_build' }"
44
+ else
45
+ # Unix commands
46
+ RM = rm -rf
47
+ RM_SITE = rm -rf site/
48
+ FIND_PYCACHE = find . -type d -name "__pycache__" -exec rm -rf {} +
49
+ RM_GENERATED_DOCS = rm -rf docs_build/
50
+ endif
51
+
52
+ docs-generate:
53
+ uv run --group docs python devtools/prepare_docs.py
54
+ uv run --group docs python docs/gen_reference.py --output-dir docs_build
55
+ uv run --group docs python docs/gen_llms.py --docs-dir docs_build --output-file docs_build/llms.txt
56
+
57
+ docs-serve: docs-generate
58
+ uv run --group docs zensical serve
59
+
60
+ docs-clean:
61
+ $(RM_SITE)
62
+ $(RM_GENERATED_DOCS)
63
+
64
+ docs-generated-clean:
65
+ $(RM_GENERATED_DOCS)
66
+
67
+ docs-build: docs-clean
68
+ uv run --group docs python devtools/prepare_docs.py
69
+ uv run --group docs python docs/gen_reference.py --output-dir docs_build
70
+ uv run --group docs python docs/gen_llms.py --docs-dir docs_build --output-file docs_build/llms.txt
71
+ uv run --group docs zensical build --clean --strict
72
+
73
+ docs-check: docs-build
74
+ uv run --group docs python devtools/check_docs_site.py docs_build site
75
+ uv run --group docs python .github/scripts/check_public_site.py site
76
+
77
+ clean:
78
+ $(RM) dist/
79
+ $(RM) *.egg-info/
80
+ $(RM) .pytest_cache/
81
+ $(RM) .mypy_cache/
82
+ $(RM) .venv/
83
+ $(FIND_PYCACHE)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pyclickplc
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Utilities for AutomationDirect CLICK PLCs: Modbus TCP client/server, address helpers, nickname CSV I/O, and DataView CDV I/O.
5
5
  Project-URL: Repository, https://github.com/ssweber/pyclickplc
6
6
  Project-URL: Documentation, https://ssweber.github.io/pyclickplc/
@@ -0,0 +1,140 @@
1
+ """Validate generated documentation inputs and their published routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ from pathlib import Path, PurePosixPath
8
+ from urllib.parse import unquote, urlparse
9
+
10
+ EXPECTED_REFERENCE_SOURCES = {
11
+ PurePosixPath("reference/index.md"),
12
+ PurePosixPath("reference/api/client.md"),
13
+ PurePosixPath("reference/api/service.md"),
14
+ PurePosixPath("reference/api/server.md"),
15
+ PurePosixPath("reference/api/files.md"),
16
+ PurePosixPath("reference/api/addressing.md"),
17
+ PurePosixPath("reference/api/validation.md"),
18
+ PurePosixPath("reference/api/advanced.md"),
19
+ }
20
+ EXPECTED_AUTHORED_SOURCES = {
21
+ PurePosixPath("index.md"),
22
+ PurePosixPath("getting-started/quickstart.md"),
23
+ PurePosixPath("guides/client.md"),
24
+ PurePosixPath("guides/types.md"),
25
+ PurePosixPath("guides/addressing.md"),
26
+ PurePosixPath("guides/modbus_service.md"),
27
+ PurePosixPath("guides/server.md"),
28
+ PurePosixPath("guides/files.md"),
29
+ PurePosixPath("guides/examples.md"),
30
+ PurePosixPath("releases/v0.2.0.md"),
31
+ PurePosixPath("releases/v0.3.0.md"),
32
+ }
33
+ EXPECTED_SITEMAP_SOURCES = (EXPECTED_AUTHORED_SOURCES | EXPECTED_REFERENCE_SOURCES) - {
34
+ PurePosixPath("releases/v0.3.0.md")
35
+ }
36
+ MARKDOWN_LINK = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
37
+
38
+
39
+ def ascii_text(value: object) -> str:
40
+ return str(value).encode("ascii", errors="backslashreplace").decode("ascii")
41
+
42
+
43
+ def _route_for_source(source: PurePosixPath) -> PurePosixPath:
44
+ if source.name == "index.md":
45
+ return source.with_name("index.html")
46
+ return source.with_suffix("") / "index.html"
47
+
48
+
49
+ def _route_for_url(url: str, site_prefix: str) -> PurePosixPath | None:
50
+ parsed = urlparse(url)
51
+ path = unquote(parsed.path)
52
+ if not path.startswith(site_prefix):
53
+ return None
54
+ relative = path[len(site_prefix) :].lstrip("/")
55
+ if not relative or path.endswith("/"):
56
+ return PurePosixPath(relative) / "index.html"
57
+ candidate = PurePosixPath(relative)
58
+ return candidate if candidate.suffix else candidate / "index.html"
59
+
60
+
61
+ def main() -> int:
62
+ parser = argparse.ArgumentParser(description=__doc__)
63
+ parser.add_argument("docs", type=Path, help="Documentation source directory")
64
+ parser.add_argument("site", type=Path, help="Generated site directory")
65
+ args = parser.parse_args()
66
+
67
+ docs = args.docs.resolve()
68
+ site = args.site.resolve()
69
+ errors: list[str] = []
70
+
71
+ actual_authored = {
72
+ PurePosixPath(path.relative_to(docs).as_posix())
73
+ for path in docs.rglob("*.md")
74
+ if "reference" not in path.relative_to(docs).parts
75
+ and path.name.lower() not in {"agents.md", "claude.md"}
76
+ }
77
+ if actual_authored != EXPECTED_AUTHORED_SOURCES:
78
+ missing = sorted(EXPECTED_AUTHORED_SOURCES - actual_authored)
79
+ extra = sorted(actual_authored - EXPECTED_AUTHORED_SOURCES)
80
+ if missing:
81
+ errors.append("missing authored sources: " + ", ".join(map(str, missing)))
82
+ if extra:
83
+ errors.append("unexpected authored sources: " + ", ".join(map(str, extra)))
84
+
85
+ actual_references = {
86
+ PurePosixPath(path.relative_to(docs).as_posix())
87
+ for path in (docs / "reference").rglob("*.md")
88
+ }
89
+ if actual_references != EXPECTED_REFERENCE_SOURCES:
90
+ missing = sorted(EXPECTED_REFERENCE_SOURCES - actual_references)
91
+ extra = sorted(actual_references - EXPECTED_REFERENCE_SOURCES)
92
+ if missing:
93
+ errors.append("missing generated reference sources: " + ", ".join(map(str, missing)))
94
+ if extra:
95
+ errors.append("unexpected generated reference sources: " + ", ".join(map(str, extra)))
96
+
97
+ sitemap_file = site / "sitemap.xml"
98
+ sitemap = sitemap_file.read_text(encoding="utf-8") if sitemap_file.is_file() else ""
99
+ for source in sorted(EXPECTED_AUTHORED_SOURCES | EXPECTED_REFERENCE_SOURCES):
100
+ route = _route_for_source(source)
101
+ if not (site / Path(route.as_posix())).is_file():
102
+ errors.append(f"missing documentation route: {route}")
103
+ if source in EXPECTED_SITEMAP_SOURCES:
104
+ public_route = "/pyclickplc/" + route.parent.as_posix().strip(".") + "/"
105
+ if public_route.replace("//", "/") not in sitemap:
106
+ errors.append(f"documentation route missing from sitemap: {public_route}")
107
+
108
+ source_llms = docs / "llms.txt"
109
+ built_llms = site / "llms.txt"
110
+ if not source_llms.is_file():
111
+ errors.append("missing generated docs/llms.txt")
112
+ elif not built_llms.is_file():
113
+ errors.append("missing published site/llms.txt")
114
+ elif source_llms.read_bytes() != built_llms.read_bytes():
115
+ errors.append("published llms.txt does not match the generated source")
116
+ else:
117
+ llms_text = built_llms.read_text(encoding="utf-8")
118
+ for url in MARKDOWN_LINK.findall(llms_text):
119
+ if urlparse(url).path.endswith(".md"):
120
+ errors.append(f"llms.txt uses a Markdown route: {url}")
121
+ route = _route_for_url(url, "/pyclickplc/")
122
+ if route is not None and not (site / Path(route.as_posix())).is_file():
123
+ errors.append(f"llms.txt target is not published: {url}")
124
+
125
+ for llms_full in (docs / "llms-full.txt", site / "llms-full.txt"):
126
+ if llms_full.exists():
127
+ errors.append(f"llms-full.txt must not be generated: {llms_full}")
128
+
129
+ if errors:
130
+ for error in errors:
131
+ print(f"ERROR: {ascii_text(error)}")
132
+ print(f"ERROR: docs-site check found {len(errors)} problem(s).")
133
+ return 1
134
+
135
+ print("OK: authored/generated docs inputs, routes, and llms.txt match.")
136
+ return 0
137
+
138
+
139
+ if __name__ == "__main__":
140
+ raise SystemExit(main())
@@ -0,0 +1,80 @@
1
+ """Prepare publishable authored documentation in an ignored build directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ ROOT_DIR = Path(__file__).resolve().parent.parent
10
+ SOURCE_DOCS_DIR = ROOT_DIR / "docs"
11
+ OUTPUT_DOCS_DIR = ROOT_DIR / "docs_build"
12
+ BLOCKED_FILENAMES = {
13
+ "agents.md",
14
+ "claude.md",
15
+ "gen_llms.py",
16
+ "gen_reference.py",
17
+ "llms-full.txt",
18
+ "llms.txt",
19
+ }
20
+
21
+
22
+ def _is_publishable(relative_path: Path) -> bool:
23
+ parts = tuple(part.lower() for part in relative_path.parts)
24
+ if "internal" in parts or "__pycache__" in parts:
25
+ return False
26
+ if parts and parts[0] == "reference":
27
+ return False
28
+ if relative_path.suffix.lower() in {".py", ".pyc", ".pyo"}:
29
+ return False
30
+ return relative_path.name.lower() not in BLOCKED_FILENAMES
31
+
32
+
33
+ def prepare(
34
+ source_dir: Path = SOURCE_DOCS_DIR,
35
+ output_dir: Path = OUTPUT_DOCS_DIR,
36
+ ) -> tuple[Path, ...]:
37
+ """Copy publishable source files into a clean generated docs directory."""
38
+ source_dir = source_dir.resolve()
39
+ output_dir = output_dir.resolve()
40
+ if output_dir == source_dir or output_dir.is_relative_to(source_dir):
41
+ raise RuntimeError("Prepared docs directory must be outside source docs.")
42
+
43
+ if output_dir.exists():
44
+ shutil.rmtree(output_dir)
45
+ output_dir.mkdir(parents=True)
46
+
47
+ copied: list[Path] = []
48
+ for source in sorted(path for path in source_dir.rglob("*") if path.is_file()):
49
+ relative_path = source.relative_to(source_dir)
50
+ if not _is_publishable(relative_path):
51
+ continue
52
+ destination = output_dir / relative_path
53
+ destination.parent.mkdir(parents=True, exist_ok=True)
54
+ shutil.copy2(source, destination)
55
+ copied.append(destination)
56
+ return tuple(copied)
57
+
58
+
59
+ def main() -> int:
60
+ parser = argparse.ArgumentParser(description=__doc__)
61
+ parser.add_argument(
62
+ "--source-dir",
63
+ type=Path,
64
+ default=SOURCE_DOCS_DIR,
65
+ help="Authored documentation directory",
66
+ )
67
+ parser.add_argument(
68
+ "--output-dir",
69
+ type=Path,
70
+ default=OUTPUT_DOCS_DIR,
71
+ help="Ignored directory to receive publishable documentation",
72
+ )
73
+ args = parser.parse_args()
74
+ copied = prepare(args.source_dir, args.output_dir)
75
+ print(f"Prepared {len(copied)} authored documentation file(s).")
76
+ return 0
77
+
78
+
79
+ if __name__ == "__main__":
80
+ raise SystemExit(main())
@@ -1,4 +1,4 @@
1
- # pyclickplc Documentation — Context for Claude
1
+ # pyclickplc Documentation — Agent Context
2
2
 
3
3
  ## What is pyclickplc
4
4
 
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,117 @@
1
+ """Generate the curated llms.txt index for the pyclickplc documentation site."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ from pathlib import Path, PurePosixPath
8
+ from urllib.parse import urljoin
9
+
10
+ DOCS_DIR = Path(__file__).resolve().parent
11
+ CONFIG_FILE = DOCS_DIR.parent / "mkdocs.yml"
12
+
13
+ SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
14
+ ("Getting Started", (("Quickstart", "getting-started/quickstart.md"),)),
15
+ (
16
+ "Guides",
17
+ (
18
+ ("Addressing", "guides/addressing.md"),
19
+ ("Client", "guides/client.md"),
20
+ ("Examples", "guides/examples.md"),
21
+ ("File I/O", "guides/files.md"),
22
+ ("Modbus Service", "guides/modbus_service.md"),
23
+ ("Server & Simulator", "guides/server.md"),
24
+ ("Types & Values", "guides/types.md"),
25
+ ),
26
+ ),
27
+ (
28
+ "API Reference",
29
+ (
30
+ ("Overview", "reference/index.md"),
31
+ ("Addressing API", "reference/api/addressing.md"),
32
+ ("Advanced API", "reference/api/advanced.md"),
33
+ ("Client API", "reference/api/client.md"),
34
+ ("Files API", "reference/api/files.md"),
35
+ ("Server API", "reference/api/server.md"),
36
+ ("Service API", "reference/api/service.md"),
37
+ ("Validation API", "reference/api/validation.md"),
38
+ ),
39
+ ),
40
+ )
41
+
42
+
43
+ def _simple_config_value(config_file: Path, key: str) -> str:
44
+ pattern = re.compile(rf"^{re.escape(key)}:\s*(.*?)\s*$")
45
+ for line in config_file.read_text(encoding="utf-8").splitlines():
46
+ if match := pattern.match(line):
47
+ return match.group(1).strip("\"'")
48
+ raise RuntimeError(f"Missing {key!r} in {config_file}.")
49
+
50
+
51
+ def _page_url(site_url: str, source_path: str) -> str:
52
+ path = PurePosixPath(source_path)
53
+ if path.name == "index.md":
54
+ route = path.parent.as_posix()
55
+ else:
56
+ route = path.with_suffix("").as_posix()
57
+ return urljoin(site_url.rstrip("/") + "/", route.rstrip("/") + "/")
58
+
59
+
60
+ def generate(
61
+ output_file: Path = DOCS_DIR / "llms.txt",
62
+ *,
63
+ docs_dir: Path = DOCS_DIR,
64
+ config_file: Path = CONFIG_FILE,
65
+ ) -> Path:
66
+ """Generate the curated HTML-route index used by public agents."""
67
+ site_name = _simple_config_value(config_file, "site_name")
68
+ site_description = _simple_config_value(config_file, "site_description")
69
+ site_url = _simple_config_value(config_file, "site_url")
70
+ lines = [f"# {site_name}", "", f"> {site_description}", ""]
71
+
72
+ for section, pages in SECTIONS:
73
+ lines.extend((f"## {section}", ""))
74
+ for label, source_path in pages:
75
+ markdown_file = docs_dir / Path(source_path)
76
+ if not markdown_file.is_file():
77
+ raise RuntimeError(f"Missing llms.txt source page: {markdown_file}.")
78
+ lines.append(f"- [{label}]({_page_url(site_url, source_path)})")
79
+ lines.append("")
80
+
81
+ output_file.parent.mkdir(parents=True, exist_ok=True)
82
+ output_file.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
83
+ return output_file
84
+
85
+
86
+ def main() -> int:
87
+ parser = argparse.ArgumentParser(description=__doc__)
88
+ parser.add_argument(
89
+ "--docs-dir",
90
+ type=Path,
91
+ default=DOCS_DIR,
92
+ help="Prepared documentation source directory",
93
+ )
94
+ parser.add_argument(
95
+ "--config-file",
96
+ type=Path,
97
+ default=CONFIG_FILE,
98
+ help="Site configuration containing name, description, and URL",
99
+ )
100
+ parser.add_argument(
101
+ "--output-file",
102
+ type=Path,
103
+ default=DOCS_DIR / "llms.txt",
104
+ help="Path to generated llms.txt",
105
+ )
106
+ args = parser.parse_args()
107
+ generate(
108
+ args.output_file.resolve(),
109
+ docs_dir=args.docs_dir.resolve(),
110
+ config_file=args.config_file.resolve(),
111
+ )
112
+ print("Generated llms.txt.")
113
+ return 0
114
+
115
+
116
+ if __name__ == "__main__":
117
+ raise SystemExit(main())