diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 210fc27..8f0a6aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,6 @@ name: CI on: - push: - branches: - - main pull_request: jobs: @@ -32,7 +29,7 @@ jobs: run: uv sync --python ${{ matrix.python-version }} - name: Run tests - run: uv run python -m unittest discover -s tests -p 'test_*.py' + run: make test build: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12ea363..b2863ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,6 +86,9 @@ jobs: contents: write steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Download distributions uses: actions/download-artifact@v4 with: diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b39c330 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: test + +test: + uv run python -m unittest discover -s tests -p 'test_*.py' diff --git a/notamify_sdk/__init__.py b/notamify_sdk/__init__.py index e95b154..ffe9c95 100644 --- a/notamify_sdk/__init__.py +++ b/notamify_sdk/__init__.py @@ -5,6 +5,11 @@ from .config import ConfigStore, SDKConfig from .models import ( ActiveNotamsQuery, + AffectedElementChangeDTO, + AffectedElementClauseDTO, + AffectedElementDTO, + AffectedElementReferenceDTO, + AffectedElementSemanticsDTO, AircraftDetails, BriefingJobCreated, BriefingJobStatusDTO, @@ -13,7 +18,10 @@ CriticalOperationalRestrictionGroup, GenerateFlightBriefingRequest, GenerateFlightBriefingResponse, + GroupedMeasurementValueDTO, + FractionalMeasurementValueDTO, HistoricalNotamsQuery, + IntegerMeasurementValueDTO, Listener, ListenerAffectedElementFilter, ListenerFilters, @@ -27,6 +35,7 @@ ListenerTimeWindowFilter, LocationType, LocationWithType, + MeasurementComponentValueDTO, NearbyNotamsQuery, NotamDTO, NotamInterpretationDTO, @@ -37,7 +46,10 @@ NotamPriority, NotamScheduleInterpretationDTO, PrioritizedNotamDTO, + ProcedureCapabilityDTO, SandboxDeliveryResult, + ValueComponentDTO, + ValueDTO, WatcherWebhookEvent, WebhookContext, WebhookLifecycleChange, @@ -53,6 +65,11 @@ __all__ = [ "APIError", "ActiveNotamsQuery", + "AffectedElementChangeDTO", + "AffectedElementClauseDTO", + "AffectedElementDTO", + "AffectedElementReferenceDTO", + "AffectedElementSemanticsDTO", "AircraftDetails", "BriefingJobCreated", "BriefingJobStatusDTO", @@ -64,7 +81,10 @@ "CriticalOperationalRestrictionGroup", "GenerateFlightBriefingRequest", "GenerateFlightBriefingResponse", + "GroupedMeasurementValueDTO", + "FractionalMeasurementValueDTO", "HistoricalNotamsQuery", + "IntegerMeasurementValueDTO", "Listener", "ListenerAffectedElementFilter", "ListenerFilters", @@ -78,6 +98,7 @@ "ListenerTimeWindowFilter", "LocationType", "LocationWithType", + "MeasurementComponentValueDTO", "NearbyNotamsQuery", "NotamDTO", "NotamInterpretationDTO", @@ -91,11 +112,14 @@ "NotamifyClient", "NotamsResource", "PrioritizedNotamDTO", + "ProcedureCapabilityDTO", "ReceiverConfig", "ReceivedEvent", "SandboxDeliveryResult", "SDKConfig", "SignatureVerificationError", + "ValueComponentDTO", + "ValueDTO", "WatcherWebhookEvent", "WebhookContext", "WebhookLifecycleChange", diff --git a/notamify_sdk/models.py b/notamify_sdk/models.py index 87ba0fc..4fce77b 100644 --- a/notamify_sdk/models.py +++ b/notamify_sdk/models.py @@ -235,11 +235,95 @@ class NotamPriority(str, Enum): low = "LOW" +class ValueComponentDTO(NotamifyModel): + type: str | None = None + value: int | str + unit: str | None = None + + +class ValueDTO(NotamifyModel): + kind: str | None = None + raw_string: str + values: list[ValueComponentDTO] = Field(default_factory=list) + + +class MeasurementComponentValueDTO(NotamifyModel): + type: str + value: int + unit: str + + +class IntegerMeasurementValueDTO(NotamifyModel): + kind: str | None = None + raw_string: str + value: int + unit: str + + +class FractionalMeasurementValueDTO(NotamifyModel): + kind: str | None = None + raw_string: str + numerator: int + denominator: int + unit: str + + +class GroupedMeasurementValueDTO(NotamifyModel): + kind: str | None = None + raw_string: str + values: list[MeasurementComponentValueDTO] = Field(default_factory=list) + + +class ProcedureCapabilityDTO(NotamifyModel): + scheme: str + category: str | None = None + level: str | None = None + classification: str | None = None + source_label: str | None = None + + +class AffectedElementReferenceDTO(NotamifyModel): + relation: str + type: str | None = None + identifier: str | None = None + + +class AffectedElementChangeDTO(NotamifyModel): + subject: str + from_: list[ProcedureCapabilityDTO | ValueDTO] = Field(default_factory=list, alias="from") + to: list[ProcedureCapabilityDTO | ValueDTO] = Field(default_factory=list) + details: str | None = None + + +class AffectedElementClauseDTO(NotamifyModel): + dimension: str + operator: str + value: ( + IntegerMeasurementValueDTO + | FractionalMeasurementValueDTO + | GroupedMeasurementValueDTO + | ProcedureCapabilityDTO + | list[str] + ) + unit: str | None = None + details: str | None = None + + +class AffectedElementSemanticsDTO(NotamifyModel): + scope: list[AffectedElementClauseDTO] = Field(default_factory=list) + conditions: list[AffectedElementClauseDTO] = Field(default_factory=list) + exceptions: list[AffectedElementClauseDTO] = Field(default_factory=list) + changes: list[AffectedElementChangeDTO] = Field(default_factory=list) + references: list[AffectedElementReferenceDTO] = Field(default_factory=list) + + class AffectedElementDTO(NotamifyModel): type: str identifier: str effect: str details: str | None = None + subtype: str | None = None + semantics: AffectedElementSemanticsDTO | None = None class NotamScheduleInterpretationDTO(NotamifyModel): diff --git a/tests/test_models.py b/tests/test_models.py index 6a92c9a..76a7ecc 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -39,6 +39,123 @@ def test_notam_list_result_validation(self): result = NotamListResult.model_validate(payload) self.assertEqual(result.notams[0].id, "n1") + def test_notam_list_result_accepts_affected_element_semantics(self): + payload = { + "notams": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "notam_number": "A1234/26", + "location": "KJFK", + "starts_at": "2026-02-25T10:00:00Z", + "ends_at": "2026-02-26T10:00:00Z", + "issued_at": "2026-02-25T09:00:00Z", + "is_estimated": False, + "is_permanent": False, + "message": "RWY 11 restrictions", + "interpretation": { + "description": "Runway operations restricted.", + "excerpt": "Runway 11 restrictions in effect.", + "category": "AERODROME", + "subcategory": "RUNWAY_OPERATIONS", + "affected_elements": [ + { + "type": "RUNWAY", + "identifier": "11", + "effect": "RESTRICTED", + "details": "Runway 11 restricted to lighter traffic.", + "subtype": "DEPARTURE_RUNWAY", + "semantics": { + "scope": [], + "conditions": [ + { + "dimension": "operation_phase", + "operator": "IN", + "value": ["TAKEOFF", "LANDING"], + "unit": None, + "details": None, + }, + { + "dimension": "weight", + "operator": "LTE", + "value": { + "kind": "MEASUREMENT", + "raw_string": "5700KG", + "value": 5700, + "unit": "KG", + }, + "unit": "KG", + "details": None, + }, + { + "dimension": "procedure_capability", + "operator": "EQ", + "value": { + "scheme": "ILS_CATEGORY", + "category": "CAT_I", + "level": "LVL_1", + }, + "unit": None, + "details": None, + }, + ], + "exceptions": [ + { + "dimension": "aircraft_type", + "operator": "IN", + "value": ["HELICOPTER"], + "unit": None, + "details": None, + } + ], + "changes": [ + { + "subject": "PROCEDURE_CAPABILITY", + "from": [ + { + "scheme": "ILS_CATEGORY", + "category": "CAT_II", + "level": "LVL_2", + } + ], + "to": [ + { + "scheme": "ILS_CATEGORY", + "category": "CAT_I", + "level": "LVL_1", + } + ], + "details": "Downgraded due to maintenance.", + } + ], + "references": [ + { + "relation": "DEPENDS_ON", + "type": "NOTAM", + "identifier": "A1234/26", + } + ], + }, + } + ], + "schedules": [], + }, + } + ], + "total_count": 1, + "page": 1, + "per_page": 30, + } + + result = NotamListResult.model_validate(payload) + affected = result.notams[0].interpretation.affected_elements[0] + + self.assertEqual(affected.subtype, "DEPARTURE_RUNWAY") + self.assertEqual(affected.semantics.conditions[0].value, ["TAKEOFF", "LANDING"]) + self.assertEqual(affected.semantics.conditions[1].value.value, 5700) + self.assertEqual(affected.semantics.conditions[2].value.scheme, "ILS_CATEGORY") + self.assertEqual(affected.semantics.changes[0].from_[0].category, "CAT_II") + self.assertEqual(affected.semantics.references[0].relation, "DEPENDS_ON") + def test_required_fields_enforced(self): with self.assertRaises(ValidationError): NotamListResult.model_validate({"notams": []}) diff --git a/tests/test_receiver.py b/tests/test_receiver.py index b0e6f15..69cdf20 100644 --- a/tests/test_receiver.py +++ b/tests/test_receiver.py @@ -9,15 +9,20 @@ class ReceiverTests(unittest.TestCase): - def test_receiver_strict_and_dev_mode(self): - receiver = WebhookReceiver(ReceiverConfig(port=18081, secret="sec", require_signature=True)) + def _start_receiver(self, **config_kwargs): + receiver = WebhookReceiver(ReceiverConfig(port=0, **config_kwargs)) receiver.start() + port = receiver._server.server_address[1] + return receiver, f"http://127.0.0.1:{port}" + + def test_receiver_strict_and_dev_mode(self): + receiver, base_url = self._start_receiver(secret="sec", require_signature=True) try: body = b'{"hello": "world"}' ts = int(time.time()) sig = compute_signature("sec", ts, body) req = request.Request( - "http://127.0.0.1:18081/", + f"{base_url}/", method="POST", data=body, headers={"Content-Type": "application/json", "X-Notamify-Signature": f"t={ts},v1={sig}"}, @@ -27,13 +32,12 @@ def test_receiver_strict_and_dev_mode(self): finally: receiver.stop() - dev_receiver = WebhookReceiver( - ReceiverConfig(port=18082, secret="", require_signature=True, allow_unsigned_dev=True) + dev_receiver, dev_base_url = self._start_receiver( + secret="", require_signature=True, allow_unsigned_dev=True ) - dev_receiver.start() try: req = request.Request( - "http://127.0.0.1:18082/", + f"{dev_base_url}/", method="POST", data=b"{}", headers={"Content-Type": "application/json"}, @@ -44,11 +48,10 @@ def test_receiver_strict_and_dev_mode(self): dev_receiver.stop() def test_receiver_rejects_unsigned_when_strict(self): - receiver = WebhookReceiver(ReceiverConfig(port=18083, secret="sec", require_signature=True)) - receiver.start() + receiver, base_url = self._start_receiver(secret="sec", require_signature=True) try: req = request.Request( - "http://127.0.0.1:18083/", + f"{base_url}/", method="POST", data=b"{}", headers={"Content-Type": "application/json"}, @@ -60,14 +63,13 @@ def test_receiver_rejects_unsigned_when_strict(self): receiver.stop() def test_receiver_accepts_query_string_on_path(self): - receiver = WebhookReceiver(ReceiverConfig(port=18084, secret="sec", require_signature=True)) - receiver.start() + receiver, base_url = self._start_receiver(secret="sec", require_signature=True) try: body = b'{"ok": true}' ts = int(time.time()) sig = compute_signature("sec", ts, body) req = request.Request( - "http://127.0.0.1:18084/?dev=1", + f"{base_url}/?dev=1", method="POST", data=body, headers={"Content-Type": "application/json", "X-Notamify-Signature": f"t={ts},v1={sig}"}, @@ -78,23 +80,20 @@ def test_receiver_accepts_query_string_on_path(self): receiver.stop() def test_receiver_strict_requires_secret(self): - receiver = WebhookReceiver( - ReceiverConfig(port=18085, secret="", require_signature=True, allow_unsigned_dev=False) - ) + receiver = WebhookReceiver(ReceiverConfig(port=0, secret="", require_signature=True, allow_unsigned_dev=False)) with self.assertRaises(ValueError): receiver.start() def test_receiver_default_secret_from_env(self): os.environ["NOTAMIFY_WEBHOOK_SECRET"] = "env-secret" try: - cfg = ReceiverConfig(port=18086, require_signature=True) + cfg = ReceiverConfig(port=0, require_signature=True) finally: del os.environ["NOTAMIFY_WEBHOOK_SECRET"] self.assertEqual(cfg.secret, "env-secret") def test_receiver_parses_typed_lifecycle_webhook(self): - receiver = WebhookReceiver(ReceiverConfig(port=18087, secret="sec", require_signature=True)) - receiver.start() + receiver, base_url = self._start_receiver(secret="sec", require_signature=True) try: body = json.dumps( { @@ -120,7 +119,7 @@ def test_receiver_parses_typed_lifecycle_webhook(self): ts = int(time.time()) sig = compute_signature("sec", ts, body) req = request.Request( - "http://127.0.0.1:18087/", + f"{base_url}/", method="POST", data=body, headers={"Content-Type": "application/json", "X-Notamify-Signature": f"t={ts},v1={sig}"},