-
Notifications
You must be signed in to change notification settings - Fork 42
feat: object-oriented rewrite — typed models with ActiveRecord behaviour #246
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Olen
wants to merge
48
commits into
main
Choose a base branch
from
feat/oo-rewrite
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 4 commits
Commits
Show all changes
48 commits
Select commit
Hold shift + click to select a range
0fcb9f9
design: spec for object-oriented rewrite
Olen 4af213d
feat(oo-rewrite): add typed model classes
Olen 205df75
feat(oo-rewrite): wire Spond.get_* to typed objects + deprecate legac…
Olen 5000d69
docs: README points at typed-object API + DESIGN-oo-rewrite.md
Olen 4e9bb72
fix: don't use locals() inside a comprehension (broke Python 3.11)
Olen 2490b30
fix(oo-rewrite): address Copilot review on #246
Olen 7ad9f92
refactor: drop _EVENT_TEMPLATE — Event class is the canonical schema
Olen 636dbc0
fix(oo-rewrite): address second-round Copilot review on #246
Olen f53853e
fix(oo-rewrite): address third-round Copilot review on #246
Olen 6cfdba0
fix(oo-rewrite): address fourth-round Copilot review on #246
Olen b9ea50e
fix(oo-rewrite): address fifth-round Copilot review on #246
Olen 0bb5245
fix(oo-rewrite): address sixth-round Copilot review on #246
Olen f69b4c7
fix(oo-rewrite): guard Post.__str__ against None timestamp
Olen 477a97e
feat(oo-rewrite): add Match as an Event subclass
Olen ed93e23
fix(oo-rewrite): round-9 review + live API field-drift audit
Olen 1ad187c
docs: add maintainer notes for periodic field-drift audit + subclass …
Olen 3f59a34
fix(oo-rewrite): round-10 review — narrow update payload further
Olen daf565e
feat(oo-rewrite): typed Chat and Message models
Olen 61a1af5
refactor(tests): split test_spond.py by domain + add Chat tests
Olen 22fb02d
docs(oo-rewrite): align design doc with shipped implementation
Olen d692fab
fix(oo-rewrite): address round-11 Copilot review
Olen 35b0374
test: improve coverage from 76% to 99% — 70 new tests across 6 files
Copilot 7764a0f
fix(oo-rewrite): address round-12 Copilot review + reformat new tests
Olen 7942ecb
fix(oo-rewrite): JSON-encode caller updates in Event.update()
Olen 220abf0
fix(oo-rewrite): round-13 review — empty-name match + fail-fast in se…
Olen 8cc4683
feat(oo-rewrite): exception hierarchy + entity-identity equality
Olen b37d25f
feat(oo-rewrite): Event convenience properties + member-resolution he…
Olen 78128dd
feat(oo-rewrite): Spond and SpondClub as async context managers
Olen 13f43fb
feat(oo-rewrite): Event.save() + Event.delete() — ActiveRecord write …
Olen 96ffc1c
docs(oo-rewrite): update README and DESIGN doc for v2.0 surface
Olen 00fbeb7
feat(oo-rewrite): typed Comment + Post.save/delete/add_comment
Olen 87dd43c
fix(oo-rewrite): address pending Copilot review on Phase 1 + 2
Olen 343086a
docs: remove local credential path reference from design doc
Olen c00f045
fix(oo-rewrite): address round-15 Copilot review on Post.save()
Olen 305cb28
fix(oo-rewrite): address round-16 Copilot review
Olen 68d350f
fix(oo-rewrite): Event.save() caches self, not a refreshed copy
Olen 8895a28
feat(oo-rewrite): Group navigation helpers + typed FieldDef
Olen fe09fd4
fix(oo-rewrite): preserve local comments across save(); use PUT for P…
Olen 2a4f02e
docs: update examples to v2.x typed surface; README v2.0 upgrade notes
Olen 8dc48b8
fix(oo-rewrite): round-19 review — three consistency fixes
Olen 010a84b
fix(oo-rewrite): replace extras on save(); aenter return type; gramma…
Olen 3e8b392
fix(oo-rewrite): round-21 review — add `.ok = True` to three more tests
Olen 9e504b7
Merge branch 'main' into feat/oo-rewrite
github-actions[bot] 7b4d4fa
Merge branch 'main' into feat/oo-rewrite
github-actions[bot] 6571a45
Merge branch 'main' into feat/oo-rewrite
Olen 6b018be
style: format README code blocks for ruff 0.16
Olen 03c2c54
Merge branch 'main' into feat/oo-rewrite
github-actions[bot] 25737b1
Revert "style: format README code blocks for ruff 0.16"
Olen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| # Spond OO rewrite — design | ||
|
|
||
| **Status:** open for feedback — work in progress on branch `feat/oo-rewrite` | ||
| **Last updated:** 2026-05-14 | ||
| **Scope:** First-class typed objects with ActiveRecord behaviour, replacing the dict-based return surface across `spond.spond.Spond` and `spond.club.SpondClub`. | ||
|
|
||
| ## Feedback welcome | ||
|
|
||
| This document is the design we're proposing for the long-discussed object-oriented rewrite of the SDK. It's the spec — not the code yet. Comments, pushback, and suggestions on any section are welcome before the implementation lands. | ||
|
|
||
| - **For high-level concerns** (API shape, scope, deprecation path) — open an issue titled `OO rewrite: …` or comment on the tracking PR. | ||
| - **For specific wording or examples** — review the draft PR (link will be added here once it's open) and comment inline. | ||
| - **For deeper design questions** — see the "Open questions" section near the end; we'd like to settle those before the implementation locks them in. | ||
|
|
||
| Decisions captured here have been agreed in principle but are still revisable as long as v1.3 hasn't shipped. | ||
|
|
||
| ## Motivation | ||
|
|
||
| The current SDK exposes one big `Spond` class with ~13 methods, all of them taking and returning `dict[str, Any]` (`JSONDict`). Callers must navigate raw dicts (`event["responses"]["acceptedIds"][0]`), there's no validation of API responses, and operations on a single event are scattered across `update_event(uid, ...)`, `change_response(uid, user, ...)`, `get_event_attendance_xlsx(uid)`. | ||
|
|
||
| The OO rewrite addresses three things at once: | ||
|
|
||
| 1. **Self-contained objects.** Operations on an event live on the event: `event.update(...)`, `event.change_response(...)`, `event.attendance_xlsx()`. | ||
| 2. **Typed navigation.** `group.members[i].guardians[j].first_name` works with autocomplete and type-checker support. | ||
| 3. **Validation on construction.** Pydantic raises loudly if Spond's API drifts, instead of silently passing through unexpected shapes. | ||
|
|
||
| ## Decisions locked during brainstorming | ||
|
|
||
| | Decision | Choice | Why | | ||
| |---|---|---| | ||
| | OO shape | **ActiveRecord** (instance owns operations) | Closest to "self-contained, well-behaved"; smaller refactor than Manager pattern; keeps `Spond.get_event(uid)` etc. on the client | | ||
| | Migration | **Side-by-side with deprecation** | Old method-on-Spond surface stays, emits `DeprecationWarning`, gets removed in a future major bump | | ||
| | Data-class tech | **Pydantic v2** | Runtime validation + clean snake_case ↔ camelCase aliasing; elliot-100's stale `oo-rewrite` branch already proved this works; ~5MB dep is fine alongside aiohttp | | ||
| | Pilot scope | **All types at once** | Half-OO state leaves inter-dependency gaps (e.g., `Group.members` returning Member objects requires Member to also be typed) | | ||
| | PR cadence | **Single draft PR** | One coherent change; most of the diff is new files | | ||
| | Person hierarchy | **Person base, Member/Guardian derived** | Members and Guardians have different behaviour: members are invited and respond to events; guardians manage a child member and may respond on their behalf | | ||
|
|
||
| ## Type inventory | ||
|
|
||
| ``` | ||
| Person (base, BaseModel + DictCompatMixin) | ||
| ├─ uid, first_name, last_name, email (optional), profile (optional) | ||
| ├─ full_name (property) | ||
| │ | ||
| ├─ Member(Person) | ||
| │ ├─ guardians: list[Guardian] | ||
| │ ├─ (subgroup memberships, roles — TBD during impl based on actual API shape) | ||
| │ └─ methods: send_message(text, group_uid) | ||
| │ | ||
| └─ Guardian(Person) | ||
| ├─ (link to managed member if API exposes it) | ||
| └─ methods: send_message(text, group_uid) — routes to guardian | ||
|
|
||
| Event(BaseModel + DictCompatMixin) | ||
| ├─ uid, heading, start_time, end_time, type: EventType, owners, recipients, ... | ||
| ├─ responses: Responses | ||
| ├─ methods: update(**fields), change_response(member_uid, *, accepted, decline_message=None), | ||
| │ attendance_xlsx() -> bytes | ||
|
|
||
| Responses (sub-object of Event) | ||
| ├─ accepted_uids, declined_uids, unanswered_uids, waiting_list_uids, unconfirmed_uids | ||
| │ — all list[str] (raw UIDs) | ||
| └─ (no methods; resolution to Member objects requires Group context — see Open Questions) | ||
|
|
||
| EventType (Enum) | ||
| └─ AVAILABILITY, EVENT, RECURRING (extend as we encounter more) | ||
|
|
||
| Group(BaseModel + DictCompatMixin) | ||
| ├─ uid, name, members: list[Member], subgroups: list[Subgroup], roles: list[Role] | ||
| └─ methods: find_member(*, email=None, name=None, uid=None) -> Member | None | ||
|
|
||
| Subgroup, Role (BaseModel + DictCompatMixin) | ||
| └─ uid, name (passive data, no methods) | ||
|
|
||
| Profile(BaseModel + DictCompatMixin) | ||
| └─ uid, first_name, last_name (passive) | ||
|
|
||
| Post(BaseModel + DictCompatMixin) | ||
| ├─ uid, title, body, timestamp, comments: list[Comment] | ||
| └─ (no methods yet; add_comment(...) deferred until we verify the Spond API supports it) | ||
|
|
||
| Comment (sub-object of Post) | ||
| └─ uid, text, timestamp, author | ||
|
|
||
| Transaction(BaseModel + DictCompatMixin) | ||
| └─ uid, paid_at, payment_name, paid_by_name (passive, Spond Club only) | ||
| ``` | ||
|
|
||
| Each typed model has a Pydantic `PrivateAttr` for the Spond/SpondClub client: | ||
|
|
||
| ```python | ||
| _client: Optional["Spond"] = PrivateAttr(default=None) | ||
| ``` | ||
|
|
||
| Construction sites set this via a `from_api(data, client)` classmethod. PrivateAttr keeps it out of `model_dump()` and pdoc. | ||
|
|
||
| ## Backward compatibility | ||
|
|
||
| ### Dict-subscript shim | ||
|
|
||
| `DictCompatMixin` gives every typed model dict-like behaviour: | ||
|
|
||
| - `event["heading"]` works, emits `DeprecationWarning` | ||
| - `event["startTimestamp"]` works (alias-aware), emits `DeprecationWarning` | ||
| - `event.get("heading", default)` works, emits warning | ||
| - `"heading" in event` works | ||
| - `for key in event` iterates the API-field names (camelCase) | ||
| - `len(event)` returns the number of fields | ||
|
|
||
| Implementation: the mixin reads `cls.model_fields` to discover both the Python attribute name and the alias, and dispatches subscript access through to attribute access. | ||
|
|
||
| ### Strict-equality test patterns | ||
|
|
||
| A small number of existing tests compare returned objects to raw dicts with `==`: | ||
|
|
||
| ```python | ||
| assert g == {"id": "ID1", "name": "Event One"} | ||
| ``` | ||
|
|
||
| These need adapting to one of: | ||
|
|
||
| ```python | ||
| assert g.uid == "ID1" and g.heading == "Event One" | ||
| # or | ||
| assert g.model_dump(by_alias=True) == {"id": "ID1", "heading": "Event One", ...} | ||
| ``` | ||
|
|
||
| This is part of the PR (one test class affected, ~5 assertions). | ||
|
|
||
| ### Legacy write methods | ||
|
|
||
| `Spond.update_event`, `Spond.change_response`, `Spond.get_event_attendance_xlsx`, `Spond.send_message` stay in v1.x — they emit `DeprecationWarning` pointing at the new method, then delegate internally: | ||
|
|
||
| ```python | ||
| async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: | ||
| warnings.warn( | ||
| "Spond.update_event is deprecated; use Event.update() instead", | ||
| DeprecationWarning, | ||
| stacklevel=2, | ||
| ) | ||
| event = await self.get_event(uid) | ||
| return await event.update(**updates) | ||
| ``` | ||
|
|
||
| All four are removed in v2.0. | ||
|
|
||
| ## Spond.get_* return-type changes | ||
|
|
||
| The seven methods that currently return `JSONDict` / `list[JSONDict] | None` change their return type but keep their names and signatures: | ||
|
|
||
| | Method | Before | After | | ||
| |---|---|---| | ||
| | `get_profile()` | `JSONDict` | `Profile` | | ||
| | `get_groups()` | `list[JSONDict] \| None` | `list[Group] \| None` | | ||
| | `get_group(uid)` | `JSONDict` | `Group` | | ||
| | `get_person(user)` | `JSONDict` | `Person` (concretely Member or Guardian) | | ||
| | `get_events(...)` | `list[JSONDict] \| None` | `list[Event] \| None` | | ||
| | `get_event(uid)` | `JSONDict` | `Event` | | ||
| | `get_posts(...)` | `list[JSONDict] \| None` | `list[Post] \| None` | | ||
| | `SpondClub.get_transactions(...)` | `list[JSONDict]` | `list[Transaction]` | | ||
|
|
||
| Dict-style consumers still work through the DictCompatMixin (with warning). | ||
|
|
||
| ## Open questions (not blocking) | ||
|
|
||
| 1. **Member ↔ UID resolution in Responses.** `Event.responses.accepted_uids` is `list[str]` not `list[Member]`. Resolving requires Group context, which Events only have via `recipients`/`groupId`. Add a helper `await event.accepted_members(spond)` that fetches the group and walks members — lazy, opt-in, no surprise HTTP from attribute reads. | ||
| 2. **Guardian.managed_member.** If the API doesn't expose a back-link, Guardian is constructed inside `Member.guardians` and the parent reference can be added post-hoc by the Member constructor. Decide during impl based on actual API shape. | ||
| 3. **Post.add_comment.** Probe whether Spond's API supports comment-add via `POST sponds/posts/{uid}/comments` or similar. If yes, add the method; if no, document as read-only. | ||
| 4. **Send-message semantics for Guardian vs Member.** Verify whether the message routes differently based on recipient kind — may require different payload shapes. | ||
|
|
||
| All four are answerable mid-impl with live API probing using credentials at `/home/olen/prog/spond-kalender/config.py`. | ||
|
|
||
| ## Files | ||
|
|
||
| **New:** | ||
| - `spond/_compat.py` — `DictCompatMixin` | ||
| - `spond/event.py` — `Event`, `Responses`, `EventType` | ||
| - `spond/person.py` — `Person`, `Member`, `Guardian` | ||
| - `spond/group.py` — `Group` | ||
| - `spond/subgroup.py` — `Subgroup` | ||
| - `spond/role.py` — `Role` | ||
| - `spond/profile.py` — `Profile` | ||
| - `spond/post.py` — `Post`, `Comment` | ||
|
|
||
| **Changed:** | ||
| - `spond/spond.py` — `get_*` methods return typed objects; legacy write methods get deprecation wrappers | ||
| - `spond/club.py` — `Transaction` model added; `get_transactions` returns `list[Transaction]` | ||
| - `pyproject.toml` — `pydantic = ">=2.0"` added to runtime deps | ||
| - `tests/test_spond.py` — strict-equality assertions adapted; new tests for ActiveRecord methods, dict-compat, inter-dependencies | ||
| - `README.md` — examples updated to OO style | ||
|
|
||
| ## Out of scope | ||
|
|
||
| - `Spond.get_messages` and the chat machinery — chats are tangled, leave on the dict-based path. Possible v1.4 follow-up. | ||
| - Removing `self.events_update` (already removed in #243). | ||
| - Adding new HTTP endpoints. This is a re-shaping of the existing surface only. | ||
| - Renaming any `Spond.get_*` method — name stability matters more than name perfection. | ||
|
|
||
| ## Test plan | ||
|
|
||
| - All existing tests pass (with the strict-equality adaptations). | ||
| - New tests for each ActiveRecord method (HTTP-mocked, asserting URL + payload + return value). | ||
| - New tests for `DictCompatMixin`: subscript works, warning fires, alias-mapped subscripts work. | ||
| - New tests for inter-dep navigation: `group.members[0]` is a `Member`, `member.guardians[0]` is a `Guardian`, etc. | ||
| - Manual smoke test of `examples/manual_test_functions.py` against live API. | ||
|
|
||
| ## Versioning | ||
|
|
||
| Land as v1.3 — minor bump (return-type change is technically breaking, but the DictCompatMixin makes it soft). Legacy `Spond.*_event*` methods removed in v2.0 after a grace period. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| """Internal helpers for backward-compatible dict-style access on typed models. | ||
|
|
||
| The pre-OO public API returned raw `dict[str, Any]` from every `get_*` method. | ||
| The OO rewrite returns Pydantic models instead. To avoid breaking existing | ||
| callers that subscript the result (`event["heading"]`, `event.get("id")`, | ||
| `"heading" in event`, …), every typed model inherits from `DictCompatModel`, | ||
| which adds dict-style read access. | ||
|
|
||
| A `DeprecationWarning` is emitted from `__getitem__` and `get()` so callers | ||
| can find their dict-style sites and migrate to attribute access. The other | ||
| dict-compat surface (`__iter__`, `keys`, `values`, `items`, `__len__`, | ||
| `__contains__`) does not warn — it's noisier and provides less signal. | ||
|
|
||
| Mutation via subscript is intentionally not supported: the typed models are | ||
| read-only at the dict-compat layer, and writes go through the ActiveRecord | ||
| methods on each type (`event.update(...)`, `event.change_response(...)`, …). | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import warnings | ||
| from collections.abc import Iterator | ||
| from datetime import date | ||
| from typing import Annotated, Any | ||
|
|
||
| from pydantic import BaseModel, BeforeValidator | ||
|
|
||
|
|
||
| def _parse_date_lenient(value: Any) -> date | None: | ||
| """Parse a date string tolerantly — return `None` for unparseable input. | ||
|
|
||
| Spond's API occasionally returns malformed `dateOfBirth` values (e.g. | ||
| `'2012-03-99'` with an impossible day). Strict ISO-8601 parsing would | ||
| raise; we want callers to keep working with `None` for that field. | ||
| """ | ||
| if value is None or isinstance(value, date): | ||
| return value | ||
| if not isinstance(value, str): | ||
| return None | ||
| try: | ||
| return date.fromisoformat(value) | ||
| except ValueError: | ||
| return None | ||
|
|
||
|
|
||
| LenientDate = Annotated[date | None, BeforeValidator(_parse_date_lenient)] | ||
| """Type alias for `dateOfBirth`-shaped fields that may contain malformed data. | ||
|
|
||
| Use this in place of `date | None` for any field where Spond's API has been | ||
| observed to return values that fail strict ISO-8601 parsing. Unparseable | ||
| values become `None` rather than raising `ValidationError`. | ||
| """ | ||
|
|
||
|
|
||
| class DictCompatModel(BaseModel): | ||
| """Pydantic base class with dict-style read access for backward compatibility. | ||
|
|
||
| Subscript access (`obj["key"]`) maps either the API-side camelCase alias or | ||
| the Python-side snake_case attribute name to the underlying attribute, | ||
| emitting a `DeprecationWarning` so callers are nudged toward attribute | ||
| access. Other dict-style operations work without warning. | ||
|
|
||
| Subclasses should inherit from this instead of directly from | ||
| `pydantic.BaseModel`. | ||
| """ | ||
|
|
||
| def _resolve_dict_key(self, key: str) -> str | None: | ||
| """Return the Python attribute name matching `key`, or None. | ||
|
|
||
| Matches either the field's API alias or its Python name. | ||
| """ | ||
| for field_name, field_info in self.__class__.model_fields.items(): | ||
| if field_info.alias == key or field_name == key: | ||
| return field_name | ||
| return None | ||
|
|
||
| def __getitem__(self, key: str) -> Any: | ||
| field_name = self._resolve_dict_key(key) | ||
| if field_name is None: | ||
| raise KeyError(key) | ||
| warnings.warn( | ||
| f"{self.__class__.__name__}[{key!r}] uses deprecated dict-style " | ||
| f"access; use attribute access (`.{field_name}`) instead", | ||
| DeprecationWarning, | ||
| stacklevel=2, | ||
| ) | ||
| return getattr(self, field_name) | ||
|
|
||
| def get(self, key: str, default: Any = None) -> Any: | ||
| """Dict-style `.get(key, default)` with deprecation warning.""" | ||
| field_name = self._resolve_dict_key(key) | ||
| if field_name is None: | ||
| return default | ||
| warnings.warn( | ||
| f"{self.__class__.__name__}.get({key!r}) uses deprecated dict-style " | ||
| f"access; use attribute access (`.{field_name}`) instead", | ||
| DeprecationWarning, | ||
| stacklevel=2, | ||
| ) | ||
| return getattr(self, field_name) | ||
|
|
||
| def __contains__(self, key: object) -> bool: | ||
| return isinstance(key, str) and self._resolve_dict_key(key) is not None | ||
|
Olen marked this conversation as resolved.
Outdated
|
||
|
|
||
| def __iter__(self) -> Iterator[str]: # type: ignore[override] | ||
| """Yield API-shaped keys (alias if defined, else field name). | ||
|
|
||
| This deliberately overrides `pydantic.BaseModel.__iter__`, which yields | ||
| `(name, value)` tuples — dict-compat callers expect just the keys. | ||
| """ | ||
| for field_name, field_info in self.__class__.model_fields.items(): | ||
| yield field_info.alias or field_name | ||
|
|
||
| def __len__(self) -> int: | ||
| return len(self.__class__.model_fields) | ||
|
Olen marked this conversation as resolved.
Outdated
|
||
|
|
||
| def keys(self) -> list[str]: | ||
| """Dict-style `.keys()` — returns the API-shaped key names.""" | ||
| return list(iter(self)) | ||
|
Olen marked this conversation as resolved.
Outdated
|
||
|
|
||
| def values(self) -> list[Any]: | ||
| """Dict-style `.values()` — returns the attribute values in field order.""" | ||
| return [getattr(self, name) for name in self.__class__.model_fields] | ||
|
|
||
| def items(self) -> list[tuple[str, Any]]: | ||
| """Dict-style `.items()` — returns (api-key, value) pairs in field order.""" | ||
| result = [] | ||
| for field_name, field_info in self.__class__.model_fields.items(): | ||
| key = field_info.alias or field_name | ||
| result.append((key, getattr(self, field_name))) | ||
| return result | ||
|
Olen marked this conversation as resolved.
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.