dex-connector-hackernews 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.5
2
+ Name: dex-connector-hackernews
3
+ Version: 0.1.0
4
+ Summary: Hacker News connector for Dex Flows
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: dex-connectors-sdk==0.1.0
8
+ Requires-Dist: dex-python-sdk>=1.28.0
9
+ Requires-Dist: httpx>=0.27
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Hacker News connector for Python
13
+
14
+ Install with `pip install dex-connector-hackernews==0.1.0`. The package
15
+ requires `dex-connectors-sdk==0.1.0` and `dex-python-sdk>=1.28.0`, and
16
+ implements the manifest Queries `listStoryIDs` and `getItem` with the same
17
+ branches, defaults, and failure classification as the Go module. The public
18
+ API needs no credentials.
19
+
20
+ ## Operations
21
+
22
+ | Operation | Factory | Happy branch | Optional branches |
23
+ | --- | --- | --- | --- |
24
+ | `listStoryIDs` | `new_list_story_ids_step` | `listed` | `providerRejected`, `invalidResponse`, `defect` |
25
+ | `getItem` | `new_get_item_step` | `found` | `notFound`, `providerRejected`, `invalidResponse`, `defect` |
26
+
27
+ `listStoryIDs` reads the `top` (default), `best`, `new`, `ask`, or `show` feed
28
+ and keeps the first `limit` IDs (default 50, at most 500), reporting whether
29
+ more remained. `getItem` returns a public story, comment, job, poll, or poll
30
+ option with its discussion URL; a missing, deleted, or dead item selects
31
+ `notFound`. 408, 429, 5xx, and transport failures retry; other non-200
32
+ responses select `providerRejected`. Item `text_html` is untrusted provider
33
+ HTML.
34
+
35
+ ## Example
36
+
37
+ [`examples/top_story`](examples/top_story/flow.py) reads a feed's first story ID
38
+ and then that story. Run the Worker with
39
+ `uv run python -m examples.top_story.worker` after `dexcli dev` has loaded a
40
+ project containing the `hackernews` connection. It is smaller than the Go
41
+ daily-digest example, which also summarizes stories with an LLM.
42
+
43
+ ## Tests
44
+
45
+ `hackernewsmock.Mock` scripts both Queries and defaults to the manifest
46
+ answers. Run `uv run pytest tests` for provider parity and manifest contract
47
+ checks.
@@ -0,0 +1,17 @@
1
+ dex_connectors/hackernews/__init__.py,sha256=CRXdSt7bbr1RfQKh1ihjpHnrowbCt78gGh87hStj1oU,1179
2
+ dex_connectors/hackernews/client.py,sha256=PezLx_mJfVL2iW2-rJtYaoCXv0XisV6v_JKJ59MVIhY,13258
3
+ dex_connectors/hackernews/connection.py,sha256=55E2yFmIuIZ3_4bMUEVHNXvPmnk0BBhJV8Z03qugOYk,4635
4
+ dex_connectors/hackernews/dex-connector.json,sha256=zqC5F0EDhuYlmxyQzkj1E9fEKGjA8XZBFqz0WKTFSWM,1883
5
+ dex_connectors/hackernews/models.py,sha256=ofY_XYYae3HXIEUTerRilVhKQjZqRdN0TVsP1g-pPDY,1804
6
+ dex_connectors/hackernews/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ dex_connectors/hackernews/steps.py,sha256=YGSs3XTl1MaCpZmAwiiHjSCC3u77GcgbHvSCJunyvng,1256
8
+ dex_connectors/hackernews/_generated/__init__.py,sha256=ShfMsAGzSt4xuhCaf20ibgtfXl4pXomZb00ovUw6CeI,66
9
+ dex_connectors/hackernews/_generated/config.py,sha256=Auq3eXNAkoGs7TwYBOJi7RdwmLCPaoV8DwAlvW1OjKw,364
10
+ dex_connectors/hackernews/_generated/definitions.py,sha256=DrOM2bpfnI10aaPOxdPixHTekW87Ft9tDw8BtJmAFiA,1993
11
+ dex_connectors/hackernews/_generated/models.py,sha256=nhwJqLAE5mKFqqJ4FvHB7JHjM5E6EN--iIi4jV_cims,4833
12
+ dex_connectors/hackernews/_generated/steps.py,sha256=moI-sBrq8pgj4n5zwgpMEZFGVFy9mG_srxa9ZTfaZ30,5666
13
+ dex_connectors/hackernews/_generated/timestamps.py,sha256=Z8VwrE60w-33InIMgEMLiMPTFyRjj6oBq8tUro8Ssi8,3164
14
+ dex_connectors/hackernews/hackernewsmock/__init__.py,sha256=NEssHqCYtuN42tlwpJANIEf9bk0kLlN2fqdqNgkrQco,3732
15
+ dex_connector_hackernews-0.1.0.dist-info/METADATA,sha256=UKL-FUQG6M74uptpkE8rNw_Lw91VNne-H9b7QmVDJ-0,1960
16
+ dex_connector_hackernews-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
17
+ dex_connector_hackernews-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,50 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Hacker News feed and item Queries for Dex connector Steps."""
4
+
5
+ from .client import (
6
+ CONNECTOR_ID,
7
+ DEFECT,
8
+ FEEDS,
9
+ FOUND,
10
+ GET_ITEM_DEFINITION,
11
+ INVALID_RESPONSE,
12
+ LIST_STORY_IDS_DEFINITION,
13
+ LISTED,
14
+ NOT_FOUND,
15
+ PROVIDER_REJECTED,
16
+ Client,
17
+ Config,
18
+ GetItemOperation,
19
+ ListStoryIDsOperation,
20
+ )
21
+ from .connection import Connection, new_connection, new_connection_with_operations, new_project_connection
22
+ from .models import GetItemInput, Item, ListStoryIDsInput, StoryIDs
23
+ from .steps import new_get_item_step, new_list_story_ids_step
24
+
25
+ __all__ = [
26
+ "Client",
27
+ "Config",
28
+ "Connection",
29
+ "GetItemInput",
30
+ "GetItemOperation",
31
+ "Item",
32
+ "ListStoryIDsInput",
33
+ "ListStoryIDsOperation",
34
+ "StoryIDs",
35
+ "new_connection",
36
+ "new_connection_with_operations",
37
+ "new_get_item_step",
38
+ "new_list_story_ids_step",
39
+ "new_project_connection",
40
+ "CONNECTOR_ID",
41
+ "DEFECT",
42
+ "FEEDS",
43
+ "FOUND",
44
+ "GET_ITEM_DEFINITION",
45
+ "INVALID_RESPONSE",
46
+ "LIST_STORY_IDS_DEFINITION",
47
+ "LISTED",
48
+ "NOT_FOUND",
49
+ "PROVIDER_REJECTED",
50
+ ]
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
@@ -0,0 +1,15 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ # ruff: noqa
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class Config:
10
+
11
+ base_url: str = "https://hacker-news.firebaseio.com/v0"
12
+ timeout_seconds: float = 15
13
+ max_response_bytes: int = 1048576
14
+
15
+ setattr(Config, '__module__', 'dex_connectors.hackernews.client')
@@ -0,0 +1,57 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ # ruff: noqa
4
+
5
+ from datetime import timedelta
6
+
7
+ from dex_connectors.sdk import (
8
+ BranchDefinition,
9
+ MutationDefinition,
10
+ OperationRef,
11
+ QueryDefinition,
12
+ RetryPolicy,
13
+ StepDefaults,
14
+ default_connector_step_execute_retry_policy,
15
+ )
16
+
17
+ CONNECTOR_ID = "hacker-news-daily"
18
+ PROVIDER = "hacker-news"
19
+ LIST_STORY_IDS_OPERATION_ID = "listStoryIDs"
20
+ GET_ITEM_OPERATION_ID = "getItem"
21
+ DEFECT = "defect"
22
+ FOUND = "found"
23
+ INVALID_RESPONSE = "invalidResponse"
24
+ LISTED = "listed"
25
+ NOT_FOUND = "notFound"
26
+ PROVIDER_REJECTED = "providerRejected"
27
+
28
+ LIST_STORY_IDS_DEFINITION = QueryDefinition(
29
+ OperationRef(CONNECTOR_ID, LIST_STORY_IDS_OPERATION_ID),
30
+ (
31
+ BranchDefinition(LISTED, "The feed snapshot was read.", False),
32
+ BranchDefinition(PROVIDER_REJECTED, "Hacker News rejected the request.", True),
33
+ BranchDefinition(INVALID_RESPONSE, "Hacker News returned malformed or oversized data.", True),
34
+ BranchDefinition(DEFECT, "Local input or connection configuration is invalid.", True),
35
+ ),
36
+ StepDefaults(
37
+ timedelta(seconds=30),
38
+ 2,
39
+ execute_retry=RetryPolicy(timedelta(seconds=1), 2, timedelta(seconds=30), 0, timedelta(seconds=3900)),
40
+ ),
41
+ )
42
+
43
+ GET_ITEM_DEFINITION = QueryDefinition(
44
+ OperationRef(CONNECTOR_ID, GET_ITEM_OPERATION_ID),
45
+ (
46
+ BranchDefinition(FOUND, "The public item was read.", False),
47
+ BranchDefinition(NOT_FOUND, "The item is missing, deleted, or dead.", True),
48
+ BranchDefinition(PROVIDER_REJECTED, "Hacker News rejected the request.", True),
49
+ BranchDefinition(INVALID_RESPONSE, "Hacker News returned malformed or oversized data.", True),
50
+ BranchDefinition(DEFECT, "Local input or connection configuration is invalid.", True),
51
+ ),
52
+ StepDefaults(
53
+ timedelta(seconds=30),
54
+ 2,
55
+ execute_retry=RetryPolicy(timedelta(seconds=1), 2, timedelta(seconds=30), 0, timedelta(seconds=3900)),
56
+ ),
57
+ )
@@ -0,0 +1,98 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ # ruff: noqa
4
+
5
+ from dataclasses import dataclass, field as _dataclass_field
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from .timestamps import parse_go_datetime, validate_go_datetime
10
+
11
+ def _replace_nil_slices(value: Any, depth: int) -> Any:
12
+ if value is None:
13
+ return ()
14
+ if depth == 1:
15
+ return tuple(value)
16
+ return tuple(_replace_nil_slices(item, depth - 1) for item in value)
17
+
18
+
19
+ # ListStoryIDsInput selects one feed snapshot. It does not select a historical time window.
20
+ @dataclass(frozen=True, slots=True)
21
+ class ListStoryIDsInput:
22
+ # Feed is top, best, new, ask, or show. Empty selects top.
23
+ feed: str = ''
24
+ # Limit bounds returned IDs from 1 through 500. Zero selects 50.
25
+ limit: int = 0
26
+
27
+
28
+ setattr(ListStoryIDsInput, '__module__', 'dex_connectors.hackernews.models')
29
+
30
+
31
+ # StoryIDs contains the start of a feed in the provider's current display order.
32
+ @dataclass(frozen=True, slots=True)
33
+ class StoryIDs:
34
+ # IDs contains at most the requested limit. Top stories can include jobs.
35
+ ids: tuple[int, ...] = ()
36
+ # HasMore reports that the API returned more IDs than this operation retained. The API has no page cursor.
37
+ has_more: bool = _dataclass_field(default=False, metadata={'dex_json_name': 'hasMore'})
38
+ # ObservedAt is the UTC time when this response was read.
39
+ observed_at: datetime = _dataclass_field(default_factory=lambda: datetime.min.replace(tzinfo=UTC), metadata={'dex_json_name': 'observedAt', 'dex_json_format': 'rfc3339'})
40
+
41
+ def __post_init__(self) -> None:
42
+ if isinstance(self.observed_at, str):
43
+ object.__setattr__(self, 'observed_at', parse_go_datetime(self.observed_at, 'observed_at'))
44
+ elif self.observed_at is not None:
45
+ validate_go_datetime(self.observed_at, 'observed_at')
46
+ object.__setattr__(self, 'ids', _replace_nil_slices(self.ids, 1))
47
+
48
+
49
+ StoryIDs.__annotations__['ids'] = tuple[int, ...] | None
50
+ StoryIDs.__dataclass_fields__['ids'].type = tuple[int, ...] | None
51
+ setattr(StoryIDs, '__module__', 'dex_connectors.hackernews.models')
52
+
53
+
54
+ # GetItemInput identifies one story, comment, job, poll, or poll option.
55
+ @dataclass(frozen=True, slots=True)
56
+ class GetItemInput:
57
+ # ID is a positive Hacker News item ID.
58
+ id: int = 0
59
+
60
+
61
+ setattr(GetItemInput, '__module__', 'dex_connectors.hackernews.models')
62
+
63
+
64
+ # Item preserves public API fields and adds the exact Hacker News permalink.
65
+ # TextHTML and Title are untrusted provider text. Applications must escape or sanitize them before HTML rendering.
66
+ @dataclass(frozen=True, slots=True)
67
+ class Item:
68
+ # ID identifies this item.
69
+ id: int = 0
70
+ # Type is story, comment, job, poll, or pollopt.
71
+ type: str = ''
72
+ # By is the author's public username, when available.
73
+ by: str = _dataclass_field(default='', metadata={'dex_json_omit_default': True})
74
+ # Time is the creation time in Unix seconds, when available.
75
+ time: int = _dataclass_field(default=0, metadata={'dex_json_omit_default': True})
76
+ # Title is the provider's title, which can contain HTML entities.
77
+ title: str = _dataclass_field(default='', metadata={'dex_json_omit_default': True})
78
+ # TextHTML is the submitted story or comment body. Linked article content is not included.
79
+ text: str = _dataclass_field(default='', metadata={'dex_json_omit_default': True})
80
+ # URL is the submitted external URL. Ask HN and other text posts may have no URL.
81
+ url: str = _dataclass_field(default='', metadata={'dex_json_omit_default': True})
82
+ # DiscussionURL links directly to this story or comment on Hacker News.
83
+ discussion_url: str = _dataclass_field(default='', metadata={'dex_json_name': 'discussionUrl'})
84
+ # Score is the story score. Hacker News does not expose comment scores.
85
+ score: int = _dataclass_field(default=0, metadata={'dex_json_omit_default': True})
86
+ # Descendants is the provider's comment count for a story or poll.
87
+ descendants: int = _dataclass_field(default=0, metadata={'dex_json_omit_default': True})
88
+ # Parent identifies a comment's parent item or a poll option's poll.
89
+ parent: int = _dataclass_field(default=0, metadata={'dex_json_omit_default': True})
90
+ # Kids contains child IDs in the provider's ranked display order.
91
+ kids: tuple[int, ...] = _dataclass_field(default=(), metadata={'dex_json_omit_default': True})
92
+ # Deleted reports a deleted item. GetItem selects notFound instead of returning a tombstone.
93
+ deleted: bool = _dataclass_field(default=False, metadata={'dex_json_omit_default': True})
94
+ # Dead reports a moderated item. GetItem selects notFound instead of returning it.
95
+ dead: bool = _dataclass_field(default=False, metadata={'dex_json_omit_default': True})
96
+
97
+
98
+ setattr(Item, '__module__', 'dex_connectors.hackernews.models')
@@ -0,0 +1,131 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ # ruff: noqa
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from typing import Any, TypeVar
7
+
8
+ import dex
9
+ from dex_connectors.sdk import (
10
+ QueryResult,
11
+ QueryStep,
12
+ QueryStepConfig,
13
+ StepAnnotations,
14
+ )
15
+
16
+ from ..connection import Connection
17
+ from ..models import GetItemInput, Item, ListStoryIDsInput, StoryIDs
18
+
19
+ StepInput = TypeVar("StepInput")
20
+
21
+ def new_list_story_ids_step(
22
+ *,
23
+ step_type: str,
24
+ step_input_type: type[StepInput],
25
+ annotations: StepAnnotations | Mapping[str, str],
26
+ connection: Connection,
27
+ connection_name: str,
28
+ map_to_operation_input: Callable[[StepInput], ListStoryIDsInput],
29
+ listed: dex.StepTarget[QueryResult[StoryIDs]],
30
+ provider_rejected: dex.StepTarget[QueryResult[StoryIDs]] | None = None,
31
+ invalid_response: dex.StepTarget[QueryResult[StoryIDs]] | None = None,
32
+ defect: dex.StepTarget[QueryResult[StoryIDs]] | None = None,
33
+ result_attribute: dex.Attribute[QueryResult[StoryIDs]] | None = None,
34
+ step_options_override: dex.StepOptions | None = None,
35
+ ) -> QueryStep[StepInput, ListStoryIDsInput, StoryIDs]:
36
+ if connection is None or connection.reference.name != connection_name:
37
+ raise ValueError("Hacker News Daily Step connection name must match its connection reference")
38
+ if isinstance(annotations, StepAnnotations):
39
+ step_annotations = annotations
40
+ else:
41
+ if not isinstance(annotations, Mapping) or set(annotations) != {"groupId", "groupLabel", "explanation"}:
42
+ raise ValueError("Hacker News Daily Step annotations require groupId, groupLabel, and explanation")
43
+ if any(not isinstance(value, str) for value in annotations.values()):
44
+ raise ValueError("Hacker News Daily Step annotations must be strings")
45
+ step_annotations = StepAnnotations(annotations["groupId"], annotations["groupLabel"], annotations["explanation"])
46
+ branches = {
47
+ "listed": listed,
48
+ }
49
+ if provider_rejected is not None:
50
+ branches["providerRejected"] = provider_rejected
51
+ if invalid_response is not None:
52
+ branches["invalidResponse"] = invalid_response
53
+ if defect is not None:
54
+ branches["defect"] = defect
55
+ return QueryStep(
56
+ QueryStepConfig(
57
+ step_type=step_type,
58
+ step_input_type=step_input_type,
59
+ annotations=step_annotations,
60
+ operation=_bound_operation(connection, "list_story_ids"),
61
+ connection=connection.reference,
62
+ map_to_operation_input=map_to_operation_input,
63
+ branches=branches,
64
+ result_attribute=result_attribute,
65
+ step_options_override=step_options_override,
66
+ )
67
+ )
68
+
69
+ setattr(new_list_story_ids_step, '__module__', 'dex_connectors.hackernews.steps')
70
+
71
+ def new_get_item_step(
72
+ *,
73
+ step_type: str,
74
+ step_input_type: type[StepInput],
75
+ annotations: StepAnnotations | Mapping[str, str],
76
+ connection: Connection,
77
+ connection_name: str,
78
+ map_to_operation_input: Callable[[StepInput], GetItemInput],
79
+ found: dex.StepTarget[QueryResult[Item]],
80
+ not_found: dex.StepTarget[QueryResult[Item]] | None = None,
81
+ provider_rejected: dex.StepTarget[QueryResult[Item]] | None = None,
82
+ invalid_response: dex.StepTarget[QueryResult[Item]] | None = None,
83
+ defect: dex.StepTarget[QueryResult[Item]] | None = None,
84
+ result_attribute: dex.Attribute[QueryResult[Item]] | None = None,
85
+ step_options_override: dex.StepOptions | None = None,
86
+ ) -> QueryStep[StepInput, GetItemInput, Item]:
87
+ if connection is None or connection.reference.name != connection_name:
88
+ raise ValueError("Hacker News Daily Step connection name must match its connection reference")
89
+ if isinstance(annotations, StepAnnotations):
90
+ step_annotations = annotations
91
+ else:
92
+ if not isinstance(annotations, Mapping) or set(annotations) != {"groupId", "groupLabel", "explanation"}:
93
+ raise ValueError("Hacker News Daily Step annotations require groupId, groupLabel, and explanation")
94
+ if any(not isinstance(value, str) for value in annotations.values()):
95
+ raise ValueError("Hacker News Daily Step annotations must be strings")
96
+ step_annotations = StepAnnotations(annotations["groupId"], annotations["groupLabel"], annotations["explanation"])
97
+ branches = {
98
+ "found": found,
99
+ }
100
+ if not_found is not None:
101
+ branches["notFound"] = not_found
102
+ if provider_rejected is not None:
103
+ branches["providerRejected"] = provider_rejected
104
+ if invalid_response is not None:
105
+ branches["invalidResponse"] = invalid_response
106
+ if defect is not None:
107
+ branches["defect"] = defect
108
+ return QueryStep(
109
+ QueryStepConfig(
110
+ step_type=step_type,
111
+ step_input_type=step_input_type,
112
+ annotations=step_annotations,
113
+ operation=_bound_operation(connection, "get_item"),
114
+ connection=connection.reference,
115
+ map_to_operation_input=map_to_operation_input,
116
+ branches=branches,
117
+ result_attribute=result_attribute,
118
+ step_options_override=step_options_override,
119
+ )
120
+ )
121
+
122
+ setattr(new_get_item_step, '__module__', 'dex_connectors.hackernews.steps')
123
+
124
+
125
+ def _bound_operation(connection: Connection, operation_name: str) -> Any:
126
+ source = getattr(connection, operation_name, None)
127
+ if source is None:
128
+ source = getattr(getattr(connection, "operations", None), operation_name, None)
129
+ if source is None:
130
+ raise ValueError(f"connection operation {operation_name} is missing")
131
+ return source() if callable(source) else source
@@ -0,0 +1,60 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ # ruff: noqa
4
+ """Go time.Time JSON input parsing for generated connector models."""
5
+
6
+ import re
7
+ from datetime import datetime
8
+
9
+ _GO_TIME = re.compile(
10
+ r"^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{1,2}):([0-9]{2}):([0-9]{2})"
11
+ r"(?:[.,]([0-9]+))?(Z|([+-])([0-9]{2}):([0-9]{2}))$"
12
+ )
13
+
14
+
15
+ def _parts(value: str, field_name: str) -> tuple[str, str, int, int]:
16
+ match = _GO_TIME.fullmatch(value)
17
+ if match is None:
18
+ raise ValueError(f"{field_name}: invalid Go time.Time JSON timestamp")
19
+ year, month, day, hour, minute, second, fraction, zone, sign, zone_hour, zone_minute = match.groups()
20
+ year_number, month_number, day_number = int(year), int(month), int(day)
21
+ is_leap = year_number % 4 == 0 and (year_number % 100 != 0 or year_number % 400 == 0)
22
+ month_days = (31, 29 if is_leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
23
+ if not (1 <= month_number <= 12 and 1 <= day_number <= month_days[month_number - 1]
24
+ and int(hour) <= 23 and int(minute) <= 59 and int(second) <= 59):
25
+ raise ValueError(f"{field_name}: invalid Go time.Time JSON timestamp")
26
+ offset_minutes = 0
27
+ if zone != "Z":
28
+ if int(zone_hour) > 24 or int(zone_minute) > 60:
29
+ raise ValueError(f"{field_name}: invalid Go time.Time JSON offset")
30
+ offset_minutes = (int(zone_hour) * 60 + int(zone_minute)) * (1 if sign == "+" else -1)
31
+ return year, f"{month}-{day}T{int(hour):02d}:{minute}:{second}", offset_minutes, int((fraction or "")[:9].ljust(9, "0"))
32
+
33
+
34
+ def canonical_go_timestamp(value: str, field_name: str) -> str:
35
+ """Parse Go's accepted JSON timestamp spellings and format like MarshalJSON."""
36
+ year, clock, offset_minutes, nanoseconds = _parts(value, field_name)
37
+ fraction = f"{nanoseconds:09d}".rstrip("0")
38
+ if abs(offset_minutes) >= 24 * 60:
39
+ return value
40
+ offset = "Z" if offset_minutes == 0 else f"{'+' if offset_minutes > 0 else '-'}{abs(offset_minutes) // 60:02d}:{abs(offset_minutes) % 60:02d}"
41
+ return f"{year}-{clock}{'.' + fraction if fraction else ''}{offset}"
42
+
43
+
44
+ def parse_go_datetime(value: str, field_name: str) -> datetime:
45
+ """Decode a Go timestamp only when Python datetime preserves its value."""
46
+ year, _, offset_minutes, nanoseconds = _parts(value, field_name)
47
+ if int(year) == 0 or nanoseconds % 1000 or abs(offset_minutes) >= 24 * 60:
48
+ raise ValueError(f"{field_name}: Go time.Time JSON timestamp cannot be represented exactly as datetime")
49
+ canonical = canonical_go_timestamp(value, field_name)
50
+ return datetime.fromisoformat(canonical.replace("Z", "+00:00"))
51
+
52
+
53
+ def validate_go_datetime(value: datetime, field_name: str) -> datetime:
54
+ """Reject Python datetimes that cannot round-trip through Go JSON time."""
55
+ if not isinstance(value, datetime):
56
+ raise ValueError(f"{field_name}: expected a datetime or Go time.Time JSON timestamp")
57
+ offset = value.utcoffset() if value.tzinfo is not None else None
58
+ if offset is None or offset.total_seconds() % 60:
59
+ raise ValueError(f"{field_name}: datetime requires a whole-minute timezone offset")
60
+ return value
@@ -0,0 +1,357 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Hacker News public API reads and Go-compatible branch classification."""
4
+
5
+ import json
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass as dataclass
8
+ from datetime import UTC, datetime
9
+ from datetime import timedelta as timedelta
10
+ from typing import Any, TypeVar
11
+
12
+ import httpx
13
+ from dex_connectors.sdk import BranchDefinition as BranchDefinition
14
+ from dex_connectors.sdk import (
15
+ Call,
16
+ Failure,
17
+ FailureKind,
18
+ OperationRef,
19
+ QueryAttempt,
20
+ QueryDefinition,
21
+ Receipt,
22
+ )
23
+ from dex_connectors.sdk import StepDefaults as StepDefaults
24
+ from dex_connectors.sdk.providerhttp import (
25
+ BodyTooLargeError,
26
+ ProviderTransportError,
27
+ new_provider_http_client,
28
+ parse_retry_after,
29
+ read_bounded_body,
30
+ send_traced,
31
+ validate_base_url,
32
+ )
33
+ from dex_connectors.sdk.retry_policy import RetryPolicy as RetryPolicy
34
+
35
+ from ._generated import definitions as _generated_definitions
36
+ from ._generated.config import Config as _GeneratedConfig
37
+ from ._generated.definitions import DEFECT as DEFECT
38
+ from ._generated.definitions import FOUND as FOUND
39
+ from ._generated.definitions import GET_ITEM_OPERATION_ID as GET_ITEM_OPERATION_ID
40
+ from ._generated.definitions import INVALID_RESPONSE as INVALID_RESPONSE
41
+ from ._generated.definitions import LIST_STORY_IDS_OPERATION_ID as LIST_STORY_IDS_OPERATION_ID
42
+ from ._generated.definitions import LISTED as LISTED
43
+ from ._generated.definitions import NOT_FOUND as NOT_FOUND
44
+ from ._generated.definitions import PROVIDER_REJECTED as PROVIDER_REJECTED
45
+ from .models import GetItemInput, Item, ListStoryIDsInput, StoryIDs
46
+
47
+ CONNECTOR_ID = "hackernews"
48
+ FAILURE_PROVIDER = "hacker-news"
49
+ FEEDS = ("top", "best", "new", "ask", "show")
50
+ ITEM_TYPES = ("story", "comment", "job", "poll", "pollopt")
51
+ _DEFAULT_BASE_URL = "https://hacker-news.firebaseio.com/v0"
52
+ _DEFAULT_TIMEOUT_SECONDS = 15
53
+ _DEFAULT_MAX_RESPONSE_BYTES = 1_048_576
54
+
55
+ LIST_STORY_IDS_DEFINITION = QueryDefinition(
56
+ OperationRef(CONNECTOR_ID, LIST_STORY_IDS_OPERATION_ID),
57
+ _generated_definitions.LIST_STORY_IDS_DEFINITION.branches or (),
58
+ _generated_definitions.LIST_STORY_IDS_DEFINITION.step_defaults,
59
+ )
60
+ GET_ITEM_DEFINITION = QueryDefinition(
61
+ OperationRef(CONNECTOR_ID, GET_ITEM_OPERATION_ID),
62
+ _generated_definitions.GET_ITEM_DEFINITION.branches or (),
63
+ _generated_definitions.GET_ITEM_DEFINITION.step_defaults,
64
+ )
65
+
66
+ Output = TypeVar("Output")
67
+
68
+
69
+ class Config(_GeneratedConfig):
70
+ """Hacker News HTTP settings; zero or blank values use manifest defaults."""
71
+
72
+ __slots__ = ()
73
+
74
+
75
+ class Client:
76
+ """One redirect-safe HTTP client for the public, unauthenticated API."""
77
+
78
+ def __init__(self, config: Config, http_client: httpx.AsyncClient | None = None) -> None:
79
+ if config is None:
80
+ raise ValueError("Hacker News configuration is required")
81
+ if config.timeout_seconds < 0 or config.max_response_bytes < 0:
82
+ raise ValueError("Hacker News timeout and maxResponseBytes must be positive")
83
+ self.base_url = validate_base_url(config.base_url or _DEFAULT_BASE_URL)
84
+ self.max_response_bytes = config.max_response_bytes or _DEFAULT_MAX_RESPONSE_BYTES
85
+ self.http_client = http_client or new_provider_http_client(
86
+ config.timeout_seconds or _DEFAULT_TIMEOUT_SECONDS
87
+ )
88
+
89
+ def list_story_ids(self) -> "ListStoryIDsOperation":
90
+ """Bind the feed snapshot Query to this client."""
91
+ return ListStoryIDsOperation(self)
92
+
93
+ def get_item(self) -> "GetItemOperation":
94
+ """Bind the item Query to this client."""
95
+ return GetItemOperation(self)
96
+
97
+ async def aclose(self) -> None:
98
+ """Close the owned provider HTTP client after application shutdown."""
99
+ await self.http_client.aclose()
100
+
101
+ async def get_json(
102
+ self, operation: str, path: str, empty: Output, decode: Callable[[bytes], QueryAttempt[Output]]
103
+ ) -> QueryAttempt[Output]:
104
+ """Send one GET and classify it exactly like the Go getJSON helper."""
105
+ try:
106
+ request = self.http_client.build_request(
107
+ "GET",
108
+ self.base_url + path,
109
+ headers={"Accept": "application/json", "User-Agent": "superdurable-hacker-news-daily/0.1"},
110
+ )
111
+ except (httpx.HTTPError, ValueError):
112
+ return _failed(
113
+ operation,
114
+ DEFECT,
115
+ empty,
116
+ FailureKind.VALIDATION,
117
+ "Hacker News request configuration is invalid",
118
+ )
119
+ try:
120
+ response, _ = await send_traced(self.http_client, request)
121
+ except ProviderTransportError:
122
+ return _retry(operation, FailureKind.AVAILABILITY, "Hacker News request failed", 0)
123
+ try:
124
+ status = response.status_code
125
+ if status in (408, 429) or status >= 500:
126
+ kind = FailureKind.RATE_LIMIT if status == 429 else FailureKind.AVAILABILITY
127
+ retry_after = parse_retry_after(response.headers.get("Retry-After", ""), datetime.now(UTC))
128
+ return _retry(operation, kind, "Hacker News is temporarily unavailable", retry_after)
129
+ if status == 404 and operation == GET_ITEM_OPERATION_ID:
130
+ return _failed(
131
+ operation, NOT_FOUND, empty, FailureKind.NOT_FOUND, "Hacker News resource was not found"
132
+ )
133
+ if status != 200:
134
+ return _failed(
135
+ operation,
136
+ PROVIDER_REJECTED,
137
+ empty,
138
+ FailureKind.PROVIDER_REJECTION,
139
+ "Hacker News rejected the request",
140
+ )
141
+ try:
142
+ body = await read_bounded_body(response, self.max_response_bytes)
143
+ except BodyTooLargeError:
144
+ return _failed(
145
+ operation,
146
+ INVALID_RESPONSE,
147
+ empty,
148
+ FailureKind.RESPONSE_TOO_LARGE,
149
+ "Hacker News response exceeded the configured limit",
150
+ )
151
+ except ProviderTransportError:
152
+ return _retry(operation, FailureKind.AVAILABILITY, "Hacker News response was interrupted", 0)
153
+ return decode(body)
154
+ finally:
155
+ await response.aclose()
156
+
157
+
158
+ class ListStoryIDsOperation:
159
+ """Read a bounded snapshot of one Hacker News feed."""
160
+
161
+ def __init__(self, client: Client) -> None:
162
+ if client is None:
163
+ raise ValueError("Hacker News client is required")
164
+ self.client = client
165
+
166
+ def definition(self) -> QueryDefinition:
167
+ """Return manifest identity, branches, and retry defaults."""
168
+ return LIST_STORY_IDS_DEFINITION
169
+
170
+ async def invoke(self, call: Call, input: ListStoryIDsInput) -> QueryAttempt[StoryIDs]:
171
+ """Validate the feed and limit, read once, then select a branch or safe retry."""
172
+ operation = LIST_STORY_IDS_OPERATION_ID
173
+ if (
174
+ not isinstance(input, ListStoryIDsInput)
175
+ or not isinstance(input.feed, str)
176
+ or type(input.limit) is not int
177
+ ):
178
+ return _failed(
179
+ operation, DEFECT, StoryIDs(), FailureKind.VALIDATION, "feed and limit are invalid"
180
+ )
181
+ feed = input.feed or "top"
182
+ limit = input.limit or 50
183
+ if feed not in FEEDS:
184
+ return _failed(
185
+ operation,
186
+ DEFECT,
187
+ StoryIDs(),
188
+ FailureKind.VALIDATION,
189
+ "feed must be top, best, new, ask, or show",
190
+ )
191
+ if not 1 <= limit <= 500:
192
+ return _failed(
193
+ operation, DEFECT, StoryIDs(), FailureKind.VALIDATION, "limit must be from 1 through 500"
194
+ )
195
+ return await self.client.get_json(
196
+ operation, f"/{feed}stories.json", StoryIDs(), lambda body: _decode_story_ids(body, limit)
197
+ )
198
+
199
+
200
+ class GetItemOperation:
201
+ """Read one public Hacker News item."""
202
+
203
+ def __init__(self, client: Client) -> None:
204
+ if client is None:
205
+ raise ValueError("Hacker News client is required")
206
+ self.client = client
207
+
208
+ def definition(self) -> QueryDefinition:
209
+ """Return manifest identity, branches, and retry defaults."""
210
+ return GET_ITEM_DEFINITION
211
+
212
+ async def invoke(self, call: Call, input: GetItemInput) -> QueryAttempt[Item]:
213
+ """Validate the item ID, read once, then select a branch or safe retry."""
214
+ operation = GET_ITEM_OPERATION_ID
215
+ if not isinstance(input, GetItemInput) or type(input.id) is not int or input.id <= 0:
216
+ return _failed(operation, DEFECT, Item(), FailureKind.VALIDATION, "item ID must be positive")
217
+ requested = input.id
218
+ return await self.client.get_json(
219
+ operation, f"/item/{requested}.json", Item(), lambda body: _decode_item(body, requested)
220
+ )
221
+
222
+
223
+ def _failed(
224
+ operation: str, branch: str, value: Output, kind: FailureKind, message: str
225
+ ) -> QueryAttempt[Output]:
226
+ return QueryAttempt.branch_result(
227
+ branch, value, Receipt(), Failure(kind, FAILURE_PROVIDER, operation, message)
228
+ )
229
+
230
+
231
+ def _retry(operation: str, kind: FailureKind, message: str, after_seconds: float) -> QueryAttempt[Any]:
232
+ return QueryAttempt.retry(Failure(kind, FAILURE_PROVIDER, operation, message), after_seconds)
233
+
234
+
235
+ def _decode_story_ids(body: bytes, limit: int) -> QueryAttempt[StoryIDs]:
236
+ operation = LIST_STORY_IDS_OPERATION_ID
237
+ try:
238
+ ids = json.loads(body)
239
+ if not isinstance(ids, list) or any(type(value) is not int for value in ids):
240
+ raise ValueError
241
+ except (ValueError, UnicodeDecodeError):
242
+ return _failed(
243
+ operation,
244
+ INVALID_RESPONSE,
245
+ StoryIDs(),
246
+ FailureKind.PROTOCOL,
247
+ "Hacker News returned an invalid feed",
248
+ )
249
+ if any(value <= 0 for value in ids):
250
+ return _failed(
251
+ operation,
252
+ INVALID_RESPONSE,
253
+ StoryIDs(),
254
+ FailureKind.PROTOCOL,
255
+ "Hacker News returned an invalid item ID",
256
+ )
257
+ return QueryAttempt.branch_result(
258
+ LISTED, StoryIDs(tuple(ids[:limit]), len(ids) > limit, datetime.now(UTC)), Receipt()
259
+ )
260
+
261
+
262
+ def _decode_item(body: bytes, requested: int) -> QueryAttempt[Item]:
263
+ operation = GET_ITEM_OPERATION_ID
264
+ try:
265
+ document = json.loads(body)
266
+ item = None if document is None else _item(document)
267
+ except (ValueError, TypeError, UnicodeDecodeError):
268
+ return _failed(
269
+ operation,
270
+ INVALID_RESPONSE,
271
+ Item(),
272
+ FailureKind.PROTOCOL,
273
+ "Hacker News returned malformed item JSON",
274
+ )
275
+ if item is None or item.deleted or item.dead:
276
+ return _failed(
277
+ operation, NOT_FOUND, Item(), FailureKind.NOT_FOUND, "Hacker News item is deleted or dead"
278
+ )
279
+ if item.id != requested:
280
+ return _failed(
281
+ operation,
282
+ INVALID_RESPONSE,
283
+ Item(),
284
+ FailureKind.PROTOCOL,
285
+ "Hacker News returned a different item ID",
286
+ )
287
+ if item.type not in ITEM_TYPES:
288
+ return _failed(
289
+ operation,
290
+ INVALID_RESPONSE,
291
+ Item(),
292
+ FailureKind.PROTOCOL,
293
+ "Hacker News returned an unknown item type",
294
+ )
295
+ if any(kid <= 0 for kid in item.kids):
296
+ return _failed(
297
+ operation,
298
+ INVALID_RESPONSE,
299
+ Item(),
300
+ FailureKind.PROTOCOL,
301
+ "Hacker News returned an invalid child ID",
302
+ )
303
+ return QueryAttempt.branch_result(FOUND, item, Receipt())
304
+
305
+
306
+ def _item(document: Any) -> Item:
307
+ if not isinstance(document, dict):
308
+ raise ValueError
309
+ kids = document.get("kids")
310
+ if kids is None:
311
+ kids = []
312
+ if not isinstance(kids, list) or any(type(kid) is not int for kid in kids):
313
+ raise ValueError
314
+ item_id = _integer(document, "id")
315
+ return Item(
316
+ id=item_id,
317
+ type=_string(document, "type"),
318
+ discussion_url=f"https://news.ycombinator.com/item?id={item_id}",
319
+ by=_string(document, "by"),
320
+ time=_integer(document, "time"),
321
+ title=_string(document, "title"),
322
+ text_html=_string(document, "text"),
323
+ url=_string(document, "url"),
324
+ score=_integer(document, "score"),
325
+ descendants=_integer(document, "descendants"),
326
+ parent=_integer(document, "parent"),
327
+ kids=tuple(kids),
328
+ deleted=_boolean(document, "deleted"),
329
+ dead=_boolean(document, "dead"),
330
+ )
331
+
332
+
333
+ def _string(document: dict[str, Any], name: str) -> str:
334
+ value = document.get(name)
335
+ if value is None:
336
+ return ""
337
+ if not isinstance(value, str):
338
+ raise ValueError
339
+ return value
340
+
341
+
342
+ def _integer(document: dict[str, Any], name: str) -> int:
343
+ value = document.get(name)
344
+ if value is None:
345
+ return 0
346
+ if type(value) is not int:
347
+ raise ValueError
348
+ return value
349
+
350
+
351
+ def _boolean(document: dict[str, Any], name: str) -> bool:
352
+ value = document.get(name)
353
+ if value is None:
354
+ return False
355
+ if not isinstance(value, bool):
356
+ raise ValueError
357
+ return value
@@ -0,0 +1,109 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ # connectorctl: handwritten facade
4
+ """Hacker News connection for a named project connection."""
5
+
6
+ from typing import Any
7
+
8
+ import httpx
9
+ from dex_connectors.sdk import ConnectionRef, Query
10
+ from dex_connectors.sdk.projectconfig import LoadedProject
11
+
12
+ from .client import CONNECTOR_ID, GET_ITEM_DEFINITION, LIST_STORY_IDS_DEFINITION, Client, Config
13
+ from .models import GetItemInput, Item, ListStoryIDsInput, StoryIDs
14
+
15
+
16
+ class Connection:
17
+ """Nonserializable Hacker News operation bindings for a named project connection."""
18
+
19
+ __slots__ = ("_list_story_ids", "_get_item", "_client", "reference")
20
+
21
+ def __init__(
22
+ self,
23
+ reference: ConnectionRef,
24
+ list_story_ids: Query[ListStoryIDsInput, StoryIDs],
25
+ get_item: Query[GetItemInput, Item],
26
+ client: Client | None = None,
27
+ ) -> None:
28
+ reference.validate()
29
+ if reference.provider != CONNECTOR_ID or list_story_ids is None or get_item is None:
30
+ raise ValueError("Hacker News connection provider and operations are required")
31
+ if (
32
+ list_story_ids.definition().operation != LIST_STORY_IDS_DEFINITION.operation
33
+ or get_item.definition().operation != GET_ITEM_DEFINITION.operation
34
+ ):
35
+ raise ValueError("Hacker News connection operation identity differs")
36
+ self.reference = reference
37
+ self._list_story_ids = list_story_ids
38
+ self._get_item = get_item
39
+ self._client = client
40
+
41
+ def list_story_ids(self) -> Query[ListStoryIDsInput, StoryIDs]:
42
+ """Return the feed snapshot Query used by an application Step."""
43
+ return self._list_story_ids
44
+
45
+ def get_item(self) -> Query[GetItemInput, Item]:
46
+ """Return the item Query used by an application Step."""
47
+ return self._get_item
48
+
49
+ async def aclose(self) -> None:
50
+ """Close the owned provider transport after Worker shutdown."""
51
+ if self._client is not None:
52
+ await self._client.aclose()
53
+
54
+ def __repr__(self) -> str:
55
+ """Keep provider transport out of logs."""
56
+ return "hackernews.Connection([REDACTED])"
57
+
58
+ def __reduce__(self) -> tuple[object, ...]:
59
+ """Prevent a live connection from entering durable Flow state."""
60
+ raise TypeError("Hacker News connections cannot be serialized")
61
+
62
+
63
+ def new_connection(client: Client, reference: ConnectionRef) -> Connection:
64
+ """Bind a client to an explicit logical connection reference."""
65
+ if client is None:
66
+ raise ValueError("Hacker News client is required")
67
+ return Connection(reference, client.list_story_ids(), client.get_item(), client)
68
+
69
+
70
+ def new_connection_with_operations(
71
+ list_story_ids: Query[ListStoryIDsInput, StoryIDs],
72
+ get_item: Query[GetItemInput, Item],
73
+ reference: ConnectionRef,
74
+ ) -> Connection:
75
+ """Bind mock operations to the same connection identity as the real provider."""
76
+ return Connection(reference, list_story_ids, get_item)
77
+
78
+
79
+ def new_project_connection(
80
+ project: LoadedProject, connection_name: str, http_client: httpx.AsyncClient | None = None
81
+ ) -> Connection:
82
+ """Open the declared connection settings; the public API needs no credentials."""
83
+ if project is None:
84
+ raise ValueError("Hacker News connection requires loaded project configuration")
85
+ reference = ConnectionRef(CONNECTOR_ID, connection_name)
86
+ reference.validate()
87
+ matching = [
88
+ entry
89
+ for entry in project.configuration.connections
90
+ if entry.connector_id == CONNECTOR_ID and entry.connection_name == connection_name
91
+ ]
92
+ if len(matching) != 1:
93
+ raise ValueError("Hacker News project connection is missing or duplicated")
94
+ if matching[0].provider != CONNECTOR_ID:
95
+ raise ValueError("Hacker News project connection provider differs")
96
+ return new_connection(Client(_decode_config(matching[0].configuration), http_client), reference)
97
+
98
+
99
+ def _decode_config(value: dict[str, Any]) -> Config:
100
+ if set(value) - {"baseUrl", "timeout", "maxResponseBytes"}:
101
+ raise ValueError("Hacker News configuration has unknown fields")
102
+ base_url = value.get("baseUrl", "")
103
+ timeout = value.get("timeout", 0)
104
+ max_response_bytes = value.get("maxResponseBytes", 0)
105
+ if not isinstance(base_url, str):
106
+ raise ValueError("Hacker News configuration baseUrl must be a string")
107
+ if type(timeout) is not int or type(max_response_bytes) is not int:
108
+ raise ValueError("Hacker News configuration timeout and maxResponseBytes must be integers")
109
+ return Config(base_url, timeout / 1_000_000_000, max_response_bytes)
@@ -0,0 +1,82 @@
1
+ {
2
+ "connectorId": "hacker-news-daily",
3
+ "releaseTag": "connectors/hackernews/go/v0.23.0",
4
+ "package": {
5
+ "ecosystem": "pypi",
6
+ "name": "dex-connector-hackernews",
7
+ "version": "0.1.0"
8
+ },
9
+ "factories": [
10
+ {
11
+ "name": "new_list_story_ids_step",
12
+ "kind": "query",
13
+ "operationId": "listStoryIDs",
14
+ "fields": {
15
+ "stepType": "step_type",
16
+ "annotations": "annotations",
17
+ "connectionName": "connection_name",
18
+ "resultAttribute": "result_attribute"
19
+ },
20
+ "branches": [
21
+ {
22
+ "id": "listed",
23
+ "field": "listed",
24
+ "optional": false
25
+ },
26
+ {
27
+ "id": "providerRejected",
28
+ "field": "provider_rejected",
29
+ "optional": true
30
+ },
31
+ {
32
+ "id": "invalidResponse",
33
+ "field": "invalid_response",
34
+ "optional": true
35
+ },
36
+ {
37
+ "id": "defect",
38
+ "field": "defect",
39
+ "optional": true
40
+ }
41
+ ]
42
+ },
43
+ {
44
+ "name": "new_get_item_step",
45
+ "kind": "query",
46
+ "operationId": "getItem",
47
+ "fields": {
48
+ "stepType": "step_type",
49
+ "annotations": "annotations",
50
+ "connectionName": "connection_name",
51
+ "resultAttribute": "result_attribute"
52
+ },
53
+ "branches": [
54
+ {
55
+ "id": "found",
56
+ "field": "found",
57
+ "optional": false
58
+ },
59
+ {
60
+ "id": "notFound",
61
+ "field": "not_found",
62
+ "optional": true
63
+ },
64
+ {
65
+ "id": "providerRejected",
66
+ "field": "provider_rejected",
67
+ "optional": true
68
+ },
69
+ {
70
+ "id": "invalidResponse",
71
+ "field": "invalid_response",
72
+ "optional": true
73
+ },
74
+ {
75
+ "id": "defect",
76
+ "field": "defect",
77
+ "optional": true
78
+ }
79
+ ]
80
+ }
81
+ ]
82
+ }
@@ -0,0 +1,119 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ # connectorctl: handwritten facade
4
+ """Manifest-backed Hacker News scripts for consuming Flow tests."""
5
+
6
+ from datetime import UTC, datetime
7
+
8
+ from dex_connectors.sdk import ConnectionRef, Failure, FailureKind
9
+ from dex_connectors.sdk.connectormock import Case, Query
10
+
11
+ from ..client import (
12
+ CONNECTOR_ID,
13
+ FAILURE_PROVIDER,
14
+ FOUND,
15
+ GET_ITEM_DEFINITION,
16
+ LIST_STORY_IDS_DEFINITION,
17
+ LISTED,
18
+ NOT_FOUND,
19
+ PROVIDER_REJECTED,
20
+ )
21
+ from ..connection import Connection, new_connection_with_operations
22
+ from ..models import GetItemInput, Item, ListStoryIDsInput, StoryIDs
23
+
24
+
25
+ def list_story_ids_listed(value: StoryIDs, failure: Failure | None = None) -> Case[StoryIDs]:
26
+ """Script the successful listed route."""
27
+ return Case.branch_result(LISTED, value, failure)
28
+
29
+
30
+ def list_story_ids_developer_feed() -> Case[StoryIDs]:
31
+ """Return the manifest's developerFeed answer."""
32
+ return list_story_ids_listed(StoryIDs((42001,), True, datetime(2026, 10, 26, 12, tzinfo=UTC)))
33
+
34
+
35
+ def list_story_ids_feed_rejected() -> Case[StoryIDs]:
36
+ """Return the manifest's feedRejected answer."""
37
+ return Case.branch_result(
38
+ PROVIDER_REJECTED,
39
+ StoryIDs(),
40
+ Failure(
41
+ FailureKind.PROVIDER_REJECTION,
42
+ FAILURE_PROVIDER,
43
+ "listStoryIDs",
44
+ "Hacker News rejected the feed request.",
45
+ ),
46
+ )
47
+
48
+
49
+ def get_item_found(value: Item, failure: Failure | None = None) -> Case[Item]:
50
+ """Script the successful found route."""
51
+ return Case.branch_result(FOUND, value, failure)
52
+
53
+
54
+ def get_item_compiler_story() -> Case[Item]:
55
+ """Return the manifest's compilerStory answer."""
56
+ return get_item_found(
57
+ Item(
58
+ id=42001,
59
+ type="story",
60
+ discussion_url="https://news.ycombinator.com/item?id=42001",
61
+ by="compilerdev",
62
+ time=1793016000,
63
+ title="A compiler release",
64
+ url="https://example.com/compiler",
65
+ score=100,
66
+ )
67
+ )
68
+
69
+
70
+ def get_item_missing() -> Case[Item]:
71
+ """Return the manifest's itemMissing answer."""
72
+ return Case.branch_result(
73
+ NOT_FOUND,
74
+ Item(),
75
+ Failure(FailureKind.NOT_FOUND, FAILURE_PROVIDER, "getItem", "Hacker News item is deleted or dead."),
76
+ )
77
+
78
+
79
+ def get_item_rejected() -> Case[Item]:
80
+ """Return the manifest's itemRejected answer."""
81
+ return Case.branch_result(
82
+ PROVIDER_REJECTED,
83
+ Item(),
84
+ Failure(
85
+ FailureKind.PROVIDER_REJECTION,
86
+ FAILURE_PROVIDER,
87
+ "getItem",
88
+ "Hacker News rejected the item request.",
89
+ ),
90
+ )
91
+
92
+
93
+ class Mock:
94
+ """Script Hacker News Queries, defaulting to their manifest answers."""
95
+
96
+ def __init__(self, connection_name: str = "hackernews") -> None:
97
+ self.list_story_ids_script: Query[ListStoryIDsInput, StoryIDs] = Query(LIST_STORY_IDS_DEFINITION)
98
+ self.list_story_ids_script.default(list_story_ids_developer_feed())
99
+ self.get_item_script: Query[GetItemInput, Item] = Query(GET_ITEM_DEFINITION)
100
+ self.get_item_script.default(get_item_compiler_story())
101
+ self._connection = new_connection_with_operations(
102
+ self.list_story_ids_script, self.get_item_script, ConnectionRef(CONNECTOR_ID, connection_name)
103
+ )
104
+
105
+ def connection(self) -> Connection:
106
+ """Return this mock's named connection for Step registration."""
107
+ return self._connection
108
+
109
+
110
+ __all__ = [
111
+ "Mock",
112
+ "list_story_ids_listed",
113
+ "list_story_ids_developer_feed",
114
+ "list_story_ids_feed_rejected",
115
+ "get_item_found",
116
+ "get_item_compiler_story",
117
+ "get_item_missing",
118
+ "get_item_rejected",
119
+ ]
@@ -0,0 +1,36 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Typed Hacker News feed snapshots and items."""
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from ._generated.models import GetItemInput as GetItemInput
8
+ from ._generated.models import ListStoryIDsInput as ListStoryIDsInput
9
+ from ._generated.models import StoryIDs as StoryIDs
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Item:
14
+ """Public story, comment, job, poll, or poll option; text_html is untrusted provider HTML."""
15
+
16
+ id: int = 0
17
+ type: str = ""
18
+ discussion_url: str = field(default="", metadata={"dex_json_name": "discussionUrl"})
19
+ by: str = field(default="", metadata={"dex_json_omit_default": True})
20
+ time: int = field(default=0, metadata={"dex_json_omit_default": True})
21
+ title: str = field(default="", metadata={"dex_json_omit_default": True})
22
+ text_html: str = field(default="", metadata={"dex_json_name": "text", "dex_json_omit_default": True})
23
+ url: str = field(default="", metadata={"dex_json_omit_default": True})
24
+ score: int = field(default=0, metadata={"dex_json_omit_default": True})
25
+ descendants: int = field(default=0, metadata={"dex_json_omit_default": True})
26
+ parent: int = field(default=0, metadata={"dex_json_omit_default": True})
27
+ kids: tuple[int, ...] = field(default=(), metadata={"dex_json_omit_default": True})
28
+ deleted: bool = field(default=False, metadata={"dex_json_omit_default": True})
29
+ dead: bool = field(default=False, metadata={"dex_json_omit_default": True})
30
+
31
+
32
+ ListStoryIDsInput.__doc__ = (
33
+ "Feed (top, best, new, ask, or show; blank means top) and limit (zero means 50, at most 500)."
34
+ )
35
+ StoryIDs.__doc__ = "Item IDs in provider order, whether the feed held more, and when the snapshot was read."
36
+ GetItemInput.__doc__ = "Positive Hacker News item ID."
File without changes
@@ -0,0 +1,35 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Public operation-specific Step factories from connector.yaml."""
4
+
5
+ from collections.abc import Callable as Callable
6
+ from collections.abc import Mapping as Mapping
7
+ from typing import TypeVar as TypeVar
8
+
9
+ import dex as dex
10
+ from dex_connectors.sdk import QueryResult as QueryResult
11
+ from dex_connectors.sdk import QueryStep as QueryStep
12
+ from dex_connectors.sdk import QueryStepConfig as QueryStepConfig
13
+ from dex_connectors.sdk import StepAnnotations as StepAnnotations
14
+
15
+ from ._generated.steps import (
16
+ StepInput as StepInput,
17
+ )
18
+ from ._generated.steps import (
19
+ new_get_item_step as new_get_item_step,
20
+ )
21
+ from ._generated.steps import (
22
+ new_list_story_ids_step as new_list_story_ids_step,
23
+ )
24
+ from .connection import Connection as Connection
25
+ from .models import GetItemInput as GetItemInput
26
+ from .models import Item as Item
27
+ from .models import ListStoryIDsInput as ListStoryIDsInput
28
+ from .models import StoryIDs as StoryIDs
29
+
30
+ BranchTarget = TypeVar("BranchTarget")
31
+
32
+ new_list_story_ids_step.__doc__ = (
33
+ "Create the typed feed snapshot Step; unwired optional outcomes fail the Flow."
34
+ )
35
+ new_get_item_step.__doc__ = "Create the typed item Step; unwired optional outcomes fail the Flow."