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.
- pyclickplc-0.3.4/.github/scripts/check_public_site.py +61 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/.github/workflows/ci.yml +3 -3
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/.github/workflows/docs.yml +18 -4
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/.github/workflows/publish.yml +3 -3
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/.gitignore +1 -0
- pyclickplc-0.3.2/CLAUDE.md → pyclickplc-0.3.4/AGENTS.md +2 -2
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/CHANGELOG.md +21 -0
- pyclickplc-0.3.4/CLAUDE.md +1 -0
- pyclickplc-0.3.4/Makefile +83 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/PKG-INFO +2 -2
- pyclickplc-0.3.4/devtools/check_docs_site.py +140 -0
- pyclickplc-0.3.4/devtools/prepare_docs.py +80 -0
- pyclickplc-0.3.2/docs/CLAUDE.md → pyclickplc-0.3.4/docs/AGENTS.md +1 -1
- pyclickplc-0.3.4/docs/CLAUDE.md +1 -0
- pyclickplc-0.3.4/docs/gen_llms.py +117 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/gen_reference.py +51 -15
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/mkdocs.yml +4 -13
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/pyproject.toml +7 -7
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/__init__.py +18 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/blocks.py +44 -4
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/nicknames.py +13 -4
- pyclickplc-0.3.4/src/pyclickplc/system/__init__.py +23 -0
- pyclickplc-0.3.4/src/pyclickplc/system/_nicknames.py +438 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_blocks.py +75 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_modbus_service.py +21 -4
- pyclickplc-0.3.4/tests/test_system_nicknames.py +81 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/uv.lock +142 -386
- pyclickplc-0.3.2/Makefile +0 -62
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/.claude/settings.local.json +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/LICENSE +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/README.md +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/devtools/lint.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/getting-started/quickstart.md +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/addressing.md +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/client.md +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/examples.md +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/files.md +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/modbus_service.md +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/server.md +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/guides/types.md +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/index.md +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/releases/v0.2.0.md +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/docs/releases/v0.3.0.md +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/examples/sync_clickplc_datetime.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/examples/traffic_light.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/examples/traffic_light_dataview.cdv +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/examples/traffic_light_nicknames.csv +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/addresses.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/banks.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/client.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/dataview.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/modbus.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/modbus_service.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/plcdata.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/py.typed +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/server.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/server_tui.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/src/pyclickplc/validation.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/__init__.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/examples/__init__.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/examples/_traffic_light_loader.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/examples/test_sync_clickplc_datetime.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/examples/test_traffic_light_cycle.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/examples/test_traffic_light_files.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/examples/test_traffic_light_smoke.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_addresses.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_banks.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_client.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_dataview.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_integration.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_modbus.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_nicknames.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_plcdata.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_server.py +0 -0
- {pyclickplc-0.3.2 → pyclickplc-0.3.4}/tests/test_server_tui.py +0 -0
- {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@
|
|
40
|
+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
41
41
|
with:
|
|
42
42
|
# Update this as needed:
|
|
43
|
-
version: "0.
|
|
44
|
-
enable-cache:
|
|
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@
|
|
27
|
+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
26
28
|
with:
|
|
27
|
-
version: "0.
|
|
28
|
-
enable-cache:
|
|
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:
|
|
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@
|
|
22
|
+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
23
23
|
with:
|
|
24
|
-
version: "0.
|
|
25
|
-
enable-cache:
|
|
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)
|
|
@@ -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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: pyclickplc
|
|
3
|
-
Version: 0.3.
|
|
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())
|
|
@@ -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())
|