osm-sdk 0.1.0.dev1__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 (55) hide show
  1. osm_sdk-0.1.0.dev1/.github/workflows/ci.yml +45 -0
  2. osm_sdk-0.1.0.dev1/.github/workflows/lint-pr-title.yml +37 -0
  3. osm_sdk-0.1.0.dev1/.github/workflows/publish.yml +37 -0
  4. osm_sdk-0.1.0.dev1/.gitignore +29 -0
  5. osm_sdk-0.1.0.dev1/CONTRIBUTING.md +49 -0
  6. osm_sdk-0.1.0.dev1/LICENSE +21 -0
  7. osm_sdk-0.1.0.dev1/PKG-INFO +75 -0
  8. osm_sdk-0.1.0.dev1/PUBLIC_API.md +114 -0
  9. osm_sdk-0.1.0.dev1/README.md +57 -0
  10. osm_sdk-0.1.0.dev1/implementation-plan.md +31 -0
  11. osm_sdk-0.1.0.dev1/osm_sdk/__init__.py +40 -0
  12. osm_sdk-0.1.0.dev1/osm_sdk/auth.py +74 -0
  13. osm_sdk-0.1.0.dev1/osm_sdk/client.py +263 -0
  14. osm_sdk-0.1.0.dev1/osm_sdk/endpoints.py +80 -0
  15. osm_sdk-0.1.0.dev1/osm_sdk/errors.py +22 -0
  16. osm_sdk-0.1.0.dev1/osm_sdk/http.py +26 -0
  17. osm_sdk-0.1.0.dev1/osm_sdk/mappers/__init__.py +15 -0
  18. osm_sdk-0.1.0.dev1/osm_sdk/mappers/common.py +159 -0
  19. osm_sdk-0.1.0.dev1/osm_sdk/mappers/contacts.py +190 -0
  20. osm_sdk-0.1.0.dev1/osm_sdk/mappers/events.py +133 -0
  21. osm_sdk-0.1.0.dev1/osm_sdk/mappers/member_type.py +28 -0
  22. osm_sdk-0.1.0.dev1/osm_sdk/mappers/members.py +56 -0
  23. osm_sdk-0.1.0.dev1/osm_sdk/mappers/programme.py +126 -0
  24. osm_sdk-0.1.0.dev1/osm_sdk/mappers/startup.py +82 -0
  25. osm_sdk-0.1.0.dev1/osm_sdk/models/__init__.py +24 -0
  26. osm_sdk-0.1.0.dev1/osm_sdk/models/attendance.py +10 -0
  27. osm_sdk-0.1.0.dev1/osm_sdk/models/attendance_status.py +9 -0
  28. osm_sdk-0.1.0.dev1/osm_sdk/models/base.py +24 -0
  29. osm_sdk-0.1.0.dev1/osm_sdk/models/contact.py +17 -0
  30. osm_sdk-0.1.0.dev1/osm_sdk/models/contact_type.py +9 -0
  31. osm_sdk-0.1.0.dev1/osm_sdk/models/event.py +26 -0
  32. osm_sdk-0.1.0.dev1/osm_sdk/models/meeting.py +31 -0
  33. osm_sdk-0.1.0.dev1/osm_sdk/models/member.py +33 -0
  34. osm_sdk-0.1.0.dev1/osm_sdk/models/member_type.py +9 -0
  35. osm_sdk-0.1.0.dev1/osm_sdk/models/section.py +46 -0
  36. osm_sdk-0.1.0.dev1/osm_sdk/models/term.py +49 -0
  37. osm_sdk-0.1.0.dev1/osm_sdk.egg-info/PKG-INFO +75 -0
  38. osm_sdk-0.1.0.dev1/osm_sdk.egg-info/SOURCES.txt +53 -0
  39. osm_sdk-0.1.0.dev1/osm_sdk.egg-info/dependency_links.txt +1 -0
  40. osm_sdk-0.1.0.dev1/osm_sdk.egg-info/requires.txt +10 -0
  41. osm_sdk-0.1.0.dev1/osm_sdk.egg-info/top_level.txt +1 -0
  42. osm_sdk-0.1.0.dev1/pyproject.toml +46 -0
  43. osm_sdk-0.1.0.dev1/setup.cfg +4 -0
  44. osm_sdk-0.1.0.dev1/tests/__init__.py +1 -0
  45. osm_sdk-0.1.0.dev1/tests/conftest.py +18 -0
  46. osm_sdk-0.1.0.dev1/tests/fixtures/oauth_resource_sample.json +30 -0
  47. osm_sdk-0.1.0.dev1/tests/integration/__init__.py +1 -0
  48. osm_sdk-0.1.0.dev1/tests/integration/test_live_auth_smoke.py +21 -0
  49. osm_sdk-0.1.0.dev1/tests/integration/test_live_section_lookup.py +22 -0
  50. osm_sdk-0.1.0.dev1/tests/unit/__init__.py +1 -0
  51. osm_sdk-0.1.0.dev1/tests/unit/support.py +285 -0
  52. osm_sdk-0.1.0.dev1/tests/unit/test_client_discovery.py +278 -0
  53. osm_sdk-0.1.0.dev1/tests/unit/test_events_and_attendance.py +121 -0
  54. osm_sdk-0.1.0.dev1/tests/unit/test_members_and_contacts.py +133 -0
  55. osm_sdk-0.1.0.dev1/tests/unit/test_programme_and_meetings.py +106 -0
@@ -0,0 +1,45 @@
1
+ name: Build and Test
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ build-and-test:
11
+ name: Build and Test
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - name: Checkout
15
+ uses: actions/checkout@v6
16
+ with:
17
+ fetch-depth: 0
18
+
19
+ - name: Setup Python
20
+ uses: actions/setup-python@v6
21
+ with:
22
+ python-version: "3.11"
23
+ cache: "pip"
24
+ cache-dependency-path: pyproject.toml
25
+
26
+ - name: Install dependencies
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ python -m pip install -e ".[dev]"
30
+
31
+ - name: Lint
32
+ run: ruff check .
33
+
34
+ - name: Test
35
+ run: pytest -q
36
+
37
+ - name: Build package
38
+ run: python -m build
39
+
40
+ - name: Upload distribution artifacts
41
+ uses: actions/upload-artifact@v4
42
+ with:
43
+ name: python-dist
44
+ path: dist/*
45
+ if-no-files-found: error
@@ -0,0 +1,37 @@
1
+ name: Lint Pull Request Title
2
+
3
+ on:
4
+ pull_request:
5
+ types:
6
+ - opened
7
+ - reopened
8
+ - edited
9
+ - synchronize
10
+
11
+ permissions:
12
+ pull-requests: write
13
+
14
+ jobs:
15
+ pull-request-title-lint:
16
+ name: Lint Pull Request Title
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - name: Lint Pull Request Title
20
+ id: lint_pr_title
21
+ uses: amannn/action-semantic-pull-request@v6
22
+ env:
23
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24
+
25
+ - name: Sticky Pull Request Comment
26
+ uses: marocchino/sticky-pull-request-comment@v2
27
+ if: always() && (steps.lint_pr_title.outputs.error_message != null)
28
+ with:
29
+ header: pr-title-lint-error
30
+ message: Pull request titles must follow the Conventional Commits specification.
31
+
32
+ - name: Delete Sticky Pull Request Comment
33
+ uses: marocchino/sticky-pull-request-comment@v2
34
+ if: ${{ steps.lint_pr_title.outputs.error_message == null }}
35
+ with:
36
+ header: pr-title-lint-error
37
+ delete: true
@@ -0,0 +1,37 @@
1
+ name: Publish Package
2
+
3
+ on:
4
+ release:
5
+ types: [ published ]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ publish:
10
+ name: Publish to PyPI
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ id-token: write
14
+ contents: read
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v6
18
+ with:
19
+ fetch-depth: 0
20
+
21
+ - name: Setup Python
22
+ uses: actions/setup-python@v6
23
+ with:
24
+ python-version: "3.11"
25
+ cache: "pip"
26
+ cache-dependency-path: pyproject.toml
27
+
28
+ - name: Install build tooling
29
+ run: |
30
+ python -m pip install --upgrade pip
31
+ python -m pip install build
32
+
33
+ - name: Build distributions
34
+ run: python -m build
35
+
36
+ - name: Publish to PyPI
37
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,29 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ *.so
7
+
8
+ # Virtual environments
9
+ .venv/
10
+ venv/
11
+ env/
12
+
13
+ # Tooling caches
14
+ .pytest_cache/
15
+ .ruff_cache/
16
+ .mypy_cache/
17
+ site/
18
+ docs_site/
19
+ dist/
20
+ build/
21
+ *.egg-info/
22
+
23
+ # IDE/editor
24
+ .idea/
25
+ .vscode/
26
+
27
+ # OS files
28
+ .DS_Store
29
+ Thumbs.db
@@ -0,0 +1,49 @@
1
+ # Contributing to OSM SDK
2
+ Contributions to OSM SDK are welcome!
3
+
4
+ OSM SDK is a Python SDK for Online Scout Manager (OSM).
5
+
6
+ This guide explains how to contribute to the project.
7
+
8
+ ## How to Contribute
9
+ There are several ways to contribute to OSM SDK:
10
+ - **Report bugs:** Find an issue and describe the problem you encountered
11
+ - **Fix bugs:** Submit a pull request with a proposed fix
12
+ - **Request features:** Suggest a new feature or improvement
13
+ - **Improve documentation:** Submit a pull request with improved documentation
14
+
15
+ ## Submitting a Pull Request
16
+ To submit a pull request, follow these steps:
17
+ - Fork the repository
18
+ - Create a new branch for your changes
19
+ - Make your changes and write tests if applicable
20
+ - Commit and push your changes to your fork
21
+ - Submit a pull request to the `main` branch of this repository
22
+
23
+ ### Pull Request Title Convention
24
+ To maintain a clean and automated changelog, this project requires pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). Titles should be formatted as follows:
25
+ `<type>: <description>`
26
+
27
+ Common types include:
28
+ - `feat`: A new feature
29
+ - `fix`: A bug fix
30
+ - `docs`: Documentation only changes
31
+ - `style`: Changes that do not affect the meaning of the code (white-space, formatting, etc.)
32
+ - `refactor`: A code change that neither fixes a bug nor adds a feature
33
+ - `test`: Adding missing tests or correcting existing tests
34
+ - `chore`: Changes to the build process or auxiliary tools and libraries such as documentation generation
35
+
36
+ ## Testing Guidelines
37
+ To ensure that changes work as expected, follow these steps:
38
+ - Use the provided pytest test framework to write tests
39
+ - Write tests for all new features or bug fixes
40
+ - Ensure all tests pass before submitting a pull request
41
+
42
+ ## Release Process
43
+ This project uses [setuptools-scm](https://setuptools-scm.readthedocs.io/) for versioning.
44
+
45
+ Versions are automatically determined by Git tags in the format `vMAJOR.MINOR.PATCH`.
46
+ To create a new release, use the GitHub UI to create a new "Release", which will automatically create the required Git tag and trigger the deployment workflow.
47
+
48
+ ## License
49
+ By contributing to OSM SDK, you agree that your contributions will be licensed under the MIT License.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Shephard
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.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: osm-sdk
3
+ Version: 0.1.0.dev1
4
+ Summary: A Python SDK for Online Scout Manager (OSM)
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: httpx>=0.27.0
9
+ Requires-Dist: pydantic>=2.0.0
10
+ Requires-Dist: email-validator>=2.0.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
13
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
14
+ Requires-Dist: respx>=0.21.0; extra == "dev"
15
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
16
+ Requires-Dist: build>=1.2.2; extra == "dev"
17
+ Dynamic: license-file
18
+
19
+ # OSM SDK
20
+
21
+ OSM SDK is a Python SDK for Online Scout Manager (OSM).
22
+
23
+ The project is provided as a PyPI package that is published on [pypi.org](https://pypi.org/project/osm-sdk/), and it can be installed by running:
24
+
25
+ ```bash
26
+ pip install osm-sdk
27
+ ```
28
+
29
+ ## Requirements
30
+
31
+ OSM SDK requires an OAuth Client ID and Oauth Secret.
32
+
33
+ These can be generated within OSM, by going to Settings → My Account Details → Developer Tools → Create Application.
34
+
35
+ Client Credentials Grant must be enabled for the application.
36
+
37
+ ## Quick Start
38
+
39
+ ```python
40
+ from osm_sdk import OSMClient, OSMAttendanceStatus
41
+
42
+ client = OSMClient(client_id="...", client_secret="...")
43
+
44
+ section = client.get_section(name="Scouts")
45
+ term = section.current_term()
46
+
47
+ for member in term.list_members():
48
+ contacts = member.list_contacts()
49
+ print(f"Member: {member.full_name} has {len(contacts)} contacts.")
50
+
51
+ for event in term.list_events():
52
+ attendance = event.list_attendance()
53
+ attending = [a for a in attendance if a.status == OSMAttendanceStatus.YES]
54
+ print(f"Event: {event.name}: {len(attending)} attending (out of {len(attendance)})")
55
+
56
+ for meeting in term.list_meetings():
57
+ attendance = meeting.list_attendance()
58
+ attending = [a for a in attendance if a.status == OSMAttendanceStatus.YES]
59
+ print(f"Meeting: {meeting.name}: {len(attending)} attending (out of {len(attendance)})")
60
+ ```
61
+
62
+ By default, `OSMClient` requests the scopes:
63
+ `section:member:read section:event:read section:programme:read section:attendance:read`.
64
+
65
+ ## Documentation
66
+
67
+ The public API is documented in [PUBLIC_API.md](PUBLIC_API.md).
68
+
69
+ ## Contributions
70
+
71
+ Contributions are welcome! Read the [contributing guide](CONTRIBUTING.md) to get started.
72
+
73
+ ## License
74
+
75
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,114 @@
1
+ # OSM SDK Public API
2
+
3
+ ## Table of Contents
4
+ - [OSMClient](#osmclient)
5
+ - [OSMSection](#osmsection)
6
+ - [OSMTerm](#osmterm)
7
+ - [OSMMember](#osmmember)
8
+ - [OSMContact](#osmcontact)
9
+ - [OSMEvent](#osmevent)
10
+ - [OSMMeeting](#osmmeeting)
11
+ - [OSMAttendance](#osmattendance)
12
+ - [Enums](#enums)
13
+
14
+ ---
15
+
16
+ ## OSMClient
17
+
18
+ ### Methods
19
+ - `get_sections() -> list[OSMSection]`
20
+ - `get_section(name: str | None = None, *, section_id: str | None = None) -> OSMSection`
21
+ - `close() -> None`
22
+
23
+ ## OSMSection
24
+
25
+ ### Properties
26
+ - `id: str`
27
+ - `name: str`
28
+
29
+ ### Methods
30
+ - `list_terms() -> list[OSMTerm]`
31
+ - `current_term() -> OSMTerm`
32
+ - `list_members(*, waiting_list: bool = False) -> list[OSMMember]`
33
+
34
+ ## OSMTerm
35
+
36
+ ### Properties
37
+ - `id: str`
38
+ - `name: str`
39
+ - `start_date: date`
40
+ - `end_date: date`
41
+ - `current: bool` (computed)
42
+
43
+ ### Methods
44
+ - `list_members() -> list[OSMMember]`
45
+ - `list_events() -> list[OSMEvent]`
46
+ - `list_meetings() -> list[OSMMeeting]`
47
+
48
+ ## OSMMember
49
+
50
+ ### Properties
51
+ - `id: str`
52
+ - `first_name: str`
53
+ - `last_name: str`
54
+ - `type: OSMMemberType`
55
+ - `date_of_birth: date | None`
56
+ - `date_joined_movement: date | None`
57
+ - `gender: str | None`
58
+ - `address: str | None`
59
+ - `postcode: str | None`
60
+ - `primary_email: str | None`
61
+ - `secondary_email: str | None`
62
+ - `primary_phone: str | None`
63
+ - `secondary_phone: str | None`
64
+ - `full_name: str` (computed)
65
+
66
+ ### Methods
67
+ - `list_contacts() -> list[OSMContact]`
68
+
69
+ ## OSMContact
70
+
71
+ ### Properties
72
+ - `type: OSMContactType`
73
+ - `first_name: str | None`
74
+ - `last_name: str | None`
75
+ - `full_name: str` (computed)
76
+ - `relationship: str | None`
77
+ - `primary_email: str | None`
78
+ - `secondary_email: str | None`
79
+ - `primary_phone: str | None`
80
+ - `secondary_phone: str | None`
81
+ - `postcode: str | None`
82
+ - `address: str | None`
83
+
84
+ ## OSMEvent
85
+
86
+ ### Properties
87
+ - `id: str`
88
+ - `name: str`
89
+ - `start_date: date`
90
+ - `end_date: date`
91
+
92
+ ### Methods
93
+ - `list_attendance() -> list[OSMAttendance]`
94
+
95
+ ## OSMMeeting
96
+
97
+ ### Properties
98
+ - `id: str`
99
+ - `name: str`
100
+ - `date: date`
101
+
102
+ ### Methods
103
+ - `list_attendance() -> list[OSMAttendance]`
104
+
105
+ ## OSMAttendance
106
+
107
+ ### Properties
108
+ - `member: OSMMember`
109
+ - `status: OSMAttendanceStatus`
110
+
111
+ ## Enums
112
+ - `OSMAttendanceStatus` (`yes`, `no`, `unknown`)
113
+ - `OSMContactType` (`PRIMARY_CONTACT`, `SECONDARY_CONTACT`, `EMERGENCY_CONTACT`)
114
+ - `OSMMemberType` (`YOUNG_PERSON`, `YOUNG_LEADER`, `LEADER`)
@@ -0,0 +1,57 @@
1
+ # OSM SDK
2
+
3
+ OSM SDK is a Python SDK for Online Scout Manager (OSM).
4
+
5
+ The project is provided as a PyPI package that is published on [pypi.org](https://pypi.org/project/osm-sdk/), and it can be installed by running:
6
+
7
+ ```bash
8
+ pip install osm-sdk
9
+ ```
10
+
11
+ ## Requirements
12
+
13
+ OSM SDK requires an OAuth Client ID and Oauth Secret.
14
+
15
+ These can be generated within OSM, by going to Settings → My Account Details → Developer Tools → Create Application.
16
+
17
+ Client Credentials Grant must be enabled for the application.
18
+
19
+ ## Quick Start
20
+
21
+ ```python
22
+ from osm_sdk import OSMClient, OSMAttendanceStatus
23
+
24
+ client = OSMClient(client_id="...", client_secret="...")
25
+
26
+ section = client.get_section(name="Scouts")
27
+ term = section.current_term()
28
+
29
+ for member in term.list_members():
30
+ contacts = member.list_contacts()
31
+ print(f"Member: {member.full_name} has {len(contacts)} contacts.")
32
+
33
+ for event in term.list_events():
34
+ attendance = event.list_attendance()
35
+ attending = [a for a in attendance if a.status == OSMAttendanceStatus.YES]
36
+ print(f"Event: {event.name}: {len(attending)} attending (out of {len(attendance)})")
37
+
38
+ for meeting in term.list_meetings():
39
+ attendance = meeting.list_attendance()
40
+ attending = [a for a in attendance if a.status == OSMAttendanceStatus.YES]
41
+ print(f"Meeting: {meeting.name}: {len(attending)} attending (out of {len(attendance)})")
42
+ ```
43
+
44
+ By default, `OSMClient` requests the scopes:
45
+ `section:member:read section:event:read section:programme:read section:attendance:read`.
46
+
47
+ ## Documentation
48
+
49
+ The public API is documented in [PUBLIC_API.md](PUBLIC_API.md).
50
+
51
+ ## Contributions
52
+
53
+ Contributions are welcome! Read the [contributing guide](CONTRIBUTING.md) to get started.
54
+
55
+ ## License
56
+
57
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,31 @@
1
+ # OSM SDK: Technical Specification & Implementation Plan (Sync-Only TDD)
2
+
3
+ This document is the authoritative specification for building the `osm-sdk`. Any agent or developer implementing this plan must follow these mandates strictly.
4
+
5
+ ## 1. Core Architectural Mandates
6
+ - **Sync-Only Implementation**: To eliminate code duplication and maintainability overhead, the SDK is **Sync-Only**. No async support is required.
7
+ - **Strict Anti-Hallucination**: The developer/agent **MUST NEVER** guess API endpoints, query parameters, or JSON structures. If the information is not provided in the current context, the agent **MUST STOP and ask the developer** for the "Ground Truth" (real JSON samples and endpoint URLs).
8
+ - **Strict TDD**: Development proceeds in a failing test -> implementation -> verification loop. Tests MUST be based on real-world OSM payload samples.
9
+ - **Strict Canonicalization**: All raw data from OSM is mapped into flat, strictly-typed Pydantic models with `extra="forbid"`. This is a hard security requirement to prevent PII leakage (medical, behavioral, etc.).
10
+ - **Person-Centric API**: While the internal data is hierarchical (`Section -> Term -> Member`), the public API must be person-centric. Methods like `member.list_contacts()` must live on the `Member` object.
11
+ - **Transparent Caching**: Tokens and Hierarchy data (`StartupData`) must be cached for the life of the client instance.
12
+
13
+ ---
14
+
15
+ ## Phase 1: Authentication & Hierarchy Discovery
16
+ - **Authenticator**: Implement the `client_credentials` flow with token caching.
17
+ - **Discovery**: Implement the `Startup` fetch to populate the internal `Section` and `Term` cache.
18
+ - **Verification**: A "Smoke Test" that confirms credentials can successfully fetch the hierarchy.
19
+
20
+ ## Phase 3: The Public API Surface (Person-Centric)
21
+ Implement the wrapper classes that provide the "Natural" API:
22
+ - `OSMSection.list_terms()` / `OSMSection.current_term()`
23
+ - `OSMTerm.list_members()` (Scoped to that term).
24
+ - `OSMMember.list_contacts()` (Resolves the linkage table to return `List[Contact]`).
25
+ - `OSMEvent.list_attendance()` (Scoped to that event).
26
+
27
+ ---
28
+
29
+ ## Communication Protocol
30
+ - **Ambiguity = Question**: If any detail is missing, ASK.
31
+ - **No Hallucinations**: Never guess a field name. If you haven't seen the JSON, you don't know the field.
@@ -0,0 +1,40 @@
1
+ from .client import OSMClient
2
+ from .errors import (
3
+ OSMAmbiguousMatchError,
4
+ OSMAPIError,
5
+ OSMAuthenticationError,
6
+ OSMDataError,
7
+ OSMError,
8
+ OSMNotFoundError,
9
+ )
10
+ from .models.attendance import OSMAttendance
11
+ from .models.attendance_status import OSMAttendanceStatus
12
+ from .models.contact import OSMContact
13
+ from .models.contact_type import OSMContactType
14
+ from .models.event import OSMEvent
15
+ from .models.meeting import OSMMeeting
16
+ from .models.member import OSMMember
17
+ from .models.member_type import OSMMemberType
18
+ from .models.section import OSMSection
19
+ from .models.term import OSMTerm
20
+
21
+ __all__ = [
22
+ "OSMClient",
23
+ "OSMAttendance",
24
+ "OSMContact",
25
+ "OSMContactType",
26
+ "OSMEvent",
27
+ "OSMMeeting",
28
+ "OSMMember",
29
+ "OSMMemberType",
30
+ "OSMSection",
31
+ "OSMTerm",
32
+ "OSMAttendanceStatus",
33
+ "OSMError",
34
+ "OSMAuthenticationError",
35
+ "OSMAPIError",
36
+ "OSMDataError",
37
+ "OSMNotFoundError",
38
+ "OSMAmbiguousMatchError",
39
+ ]
40
+
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import Optional
5
+
6
+ import httpx
7
+
8
+ from .errors import OSMAuthenticationError
9
+
10
+ DEFAULT_TOKEN_URL = "https://www.onlinescoutmanager.co.uk/oauth/token"
11
+
12
+
13
+ class Authenticator:
14
+ """OAuth2 client-credentials authenticator with in-memory token caching."""
15
+
16
+ def __init__(
17
+ self,
18
+ client_id: str,
19
+ client_secret: str,
20
+ scope: Optional[str] = None,
21
+ token_url: str = DEFAULT_TOKEN_URL,
22
+ ) -> None:
23
+ self._client_id = client_id
24
+ self._client_secret = client_secret
25
+ self._scope = scope
26
+ self._token_url = token_url
27
+ self._cached_token: Optional[str] = None
28
+ self._token_expires_at: Optional[datetime] = None
29
+
30
+ def get_access_token(self, http_client: httpx.Client) -> str:
31
+ if self._cached_token and self._token_is_valid():
32
+ return self._cached_token
33
+
34
+ payload = {"grant_type": "client_credentials"}
35
+ if self._scope:
36
+ payload["scope"] = self._scope
37
+ payload["client_id"] = self._client_id
38
+ payload["client_secret"] = self._client_secret
39
+
40
+ response = http_client.post(
41
+ self._token_url,
42
+ data=payload,
43
+ auth=(self._client_id, self._client_secret),
44
+ headers={"Accept": "application/json"},
45
+ )
46
+ if response.status_code < 200 or response.status_code >= 300:
47
+ raise OSMAuthenticationError(
48
+ f"OSM token request failed ({response.status_code}): {response.text}"
49
+ )
50
+
51
+ token_payload = response.json()
52
+ access_token = token_payload.get("access_token")
53
+ if not isinstance(access_token, str) or not access_token:
54
+ raise OSMAuthenticationError(
55
+ "OSM token response did not include a valid 'access_token' field."
56
+ )
57
+
58
+ self._cached_token = access_token
59
+ self._token_expires_at = self._compute_expiry(token_payload.get("expires_in"))
60
+ return access_token
61
+
62
+ def _token_is_valid(self) -> bool:
63
+ if self._token_expires_at is None:
64
+ return True
65
+ return datetime.now(timezone.utc) < self._token_expires_at
66
+
67
+ @staticmethod
68
+ def _compute_expiry(expires_in: object) -> Optional[datetime]:
69
+ if not isinstance(expires_in, int):
70
+ return None
71
+ if expires_in <= 0:
72
+ return None
73
+ return datetime.now(timezone.utc) + timedelta(seconds=max(0, expires_in - 30))
74
+