dex-connector-hackernews 0.1.0__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 (25) hide show
  1. dex_connector_hackernews-0.1.0/.gitignore +6 -0
  2. dex_connector_hackernews-0.1.0/PKG-INFO +47 -0
  3. dex_connector_hackernews-0.1.0/README.md +36 -0
  4. dex_connector_hackernews-0.1.0/examples/top_story/__init__.py +3 -0
  5. dex_connector_hackernews-0.1.0/examples/top_story/flow.py +133 -0
  6. dex_connector_hackernews-0.1.0/examples/top_story/worker.py +51 -0
  7. dex_connector_hackernews-0.1.0/pyproject.toml +37 -0
  8. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/__init__.py +50 -0
  9. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/_generated/__init__.py +2 -0
  10. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/_generated/config.py +15 -0
  11. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/_generated/definitions.py +57 -0
  12. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/_generated/models.py +98 -0
  13. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/_generated/steps.py +131 -0
  14. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/_generated/timestamps.py +60 -0
  15. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/client.py +357 -0
  16. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/connection.py +109 -0
  17. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/dex-connector.json +82 -0
  18. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/hackernewsmock/__init__.py +119 -0
  19. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/models.py +36 -0
  20. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/py.typed +0 -0
  21. dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/steps.py +35 -0
  22. dex_connector_hackernews-0.1.0/tests/test_conformance.py +48 -0
  23. dex_connector_hackernews-0.1.0/tests/test_manifest_contract.py +113 -0
  24. dex_connector_hackernews-0.1.0/tests/test_operations.py +146 -0
  25. dex_connector_hackernews-0.1.0/uv.lock +932 -0
@@ -0,0 +1,6 @@
1
+ /.dex-local/dexcli
2
+ /.dex-local/dex-server-zd
3
+ /.dex-local/data/
4
+ /.dex-local/cli.log
5
+ /.dex-local/visualize.json
6
+ /.dex-local/visualize.err
@@ -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,36 @@
1
+ # Hacker News connector for Python
2
+
3
+ Install with `pip install dex-connector-hackernews==0.1.0`. The package
4
+ requires `dex-connectors-sdk==0.1.0` and `dex-python-sdk>=1.28.0`, and
5
+ implements the manifest Queries `listStoryIDs` and `getItem` with the same
6
+ branches, defaults, and failure classification as the Go module. The public
7
+ API needs no credentials.
8
+
9
+ ## Operations
10
+
11
+ | Operation | Factory | Happy branch | Optional branches |
12
+ | --- | --- | --- | --- |
13
+ | `listStoryIDs` | `new_list_story_ids_step` | `listed` | `providerRejected`, `invalidResponse`, `defect` |
14
+ | `getItem` | `new_get_item_step` | `found` | `notFound`, `providerRejected`, `invalidResponse`, `defect` |
15
+
16
+ `listStoryIDs` reads the `top` (default), `best`, `new`, `ask`, or `show` feed
17
+ and keeps the first `limit` IDs (default 50, at most 500), reporting whether
18
+ more remained. `getItem` returns a public story, comment, job, poll, or poll
19
+ option with its discussion URL; a missing, deleted, or dead item selects
20
+ `notFound`. 408, 429, 5xx, and transport failures retry; other non-200
21
+ responses select `providerRejected`. Item `text_html` is untrusted provider
22
+ HTML.
23
+
24
+ ## Example
25
+
26
+ [`examples/top_story`](examples/top_story/flow.py) reads a feed's first story ID
27
+ and then that story. Run the Worker with
28
+ `uv run python -m examples.top_story.worker` after `dexcli dev` has loaded a
29
+ project containing the `hackernews` connection. It is smaller than the Go
30
+ daily-digest example, which also summarizes stories with an LLM.
31
+
32
+ ## Tests
33
+
34
+ `hackernewsmock.Mock` scripts both Queries and defaults to the manifest
35
+ answers. Run `uv run pytest tests` for provider parity and manifest contract
36
+ checks.
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Hacker News top story example Flow."""
@@ -0,0 +1,133 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Runnable Hacker News Flow: read a feed's first story ID, then that story."""
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ import dex
8
+ from dex_connectors.sdk import QueryResult
9
+
10
+ from dex_connectors.hackernews import (
11
+ Connection,
12
+ GetItemInput,
13
+ Item,
14
+ ListStoryIDsInput,
15
+ StoryIDs,
16
+ new_get_item_step,
17
+ new_list_story_ids_step,
18
+ )
19
+
20
+ FLOW_TYPE = "HackerNewsTopStory"
21
+ CONNECTION_NAME = "hackernews"
22
+ _LIST_STEP_TYPE = "ListFeedStoryIDs"
23
+ _GET_STEP_TYPE = "ReadTopStory"
24
+ _ANNOTATION_GROUP = {"groupId": "hacker-news", "groupLabel": "Hacker News"}
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class TopStoryRequest:
29
+ """Feed to read: top, best, new, ask, or show; blank means top."""
30
+
31
+ feed: str = ""
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class TopStoryOutcome:
36
+ """The feed's first story, or an empty item when the feed was empty."""
37
+
38
+ story: Item = field(default_factory=Item)
39
+
40
+
41
+ _OUTCOME = dex.Attribute("hackernews-top-story-outcome", TopStoryOutcome)
42
+
43
+
44
+ def map_to_list_story_ids_input(request: TopStoryRequest) -> ListStoryIDsInput:
45
+ """Read only the first ID of the requested feed."""
46
+ return ListStoryIDsInput(feed=request.feed, limit=1)
47
+
48
+
49
+ def map_to_get_item_input(item: GetItemInput) -> GetItemInput:
50
+ """Read the story chosen from the feed unchanged."""
51
+ return item
52
+
53
+
54
+ # dex:group group-id:top-story group-label:"Top story"
55
+ # dex:explanation text:"Start the lookup with the requested feed."
56
+ class _StartRequest(dex.Step[TopStoryRequest]):
57
+ def get_step_type(self) -> str:
58
+ return "StartTopStory"
59
+
60
+ def execute(self, context: dex.Context, input: TopStoryRequest) -> dex.StepDecision:
61
+ return dex.go_to(dex.StepRef(_LIST_STEP_TYPE), input)
62
+
63
+
64
+ # dex:group group-id:top-story group-label:"Top story"
65
+ # dex:explanation text:"Complete when the feed is empty, otherwise read its first story."
66
+ class _AfterFeed(dex.Step[QueryResult[StoryIDs]]):
67
+ def get_step_type(self) -> str:
68
+ return "AfterFeedSnapshot"
69
+
70
+ def execute(self, context: dex.Context, input: QueryResult[StoryIDs]) -> dex.StepDecision:
71
+ if not input.value.ids:
72
+ outcome = TopStoryOutcome()
73
+ _OUTCOME.set(context, outcome)
74
+ return dex.graceful_complete(outcome)
75
+ return dex.go_to(dex.StepRef(_GET_STEP_TYPE), GetItemInput(input.value.ids[0]))
76
+
77
+
78
+ # dex:group group-id:top-story group-label:"Top story"
79
+ # dex:explanation text:"Record the story and complete the Flow."
80
+ class _CompleteStory(dex.Step[QueryResult[Item]]):
81
+ def get_step_type(self) -> str:
82
+ return "CompleteTopStory"
83
+
84
+ def execute(self, context: dex.Context, input: QueryResult[Item]) -> dex.StepDecision:
85
+ outcome = TopStoryOutcome(input.value)
86
+ _OUTCOME.set(context, outcome)
87
+ return dex.graceful_complete(outcome)
88
+
89
+
90
+ class TopStoryFlow(dex.Flow[TopStoryRequest]):
91
+ """Read a feed's first story through the static hackernews connection."""
92
+
93
+ def __init__(self, connection: Connection) -> None:
94
+ if connection.reference.name != CONNECTION_NAME:
95
+ raise ValueError("top story requires the static hackernews connection")
96
+ self._start = _StartRequest()
97
+ self._after_feed = _AfterFeed()
98
+ self._complete = _CompleteStory()
99
+ self._list = new_list_story_ids_step(
100
+ step_type=_LIST_STEP_TYPE,
101
+ step_input_type=TopStoryRequest,
102
+ annotations={
103
+ **_ANNOTATION_GROUP,
104
+ "explanation": "Read the first story ID of the requested feed.",
105
+ },
106
+ connection=connection,
107
+ connection_name=CONNECTION_NAME,
108
+ map_to_operation_input=map_to_list_story_ids_input,
109
+ listed=self._after_feed,
110
+ )
111
+ self._get = new_get_item_step(
112
+ step_type=_GET_STEP_TYPE,
113
+ step_input_type=GetItemInput,
114
+ annotations={**_ANNOTATION_GROUP, "explanation": "Read the story and its discussion link."},
115
+ connection=connection,
116
+ connection_name=CONNECTION_NAME,
117
+ map_to_operation_input=map_to_get_item_input,
118
+ found=self._complete,
119
+ )
120
+
121
+ def get_flow_type(self) -> str:
122
+ """Return the stable Flow identity used in Dex Web."""
123
+ return FLOW_TYPE
124
+
125
+ def get_steps(self) -> dex.StepList[TopStoryRequest]:
126
+ """Register the start, both Hacker News reads, and their result Steps."""
127
+ return dex.StepList.start_step(self._start).other_steps(
128
+ self._list, self._after_feed, self._get, self._complete
129
+ )
130
+
131
+ def get_persistence_schema(self) -> dex.PersistenceSchema:
132
+ """Persist the story outcome."""
133
+ return dex.PersistenceSchema.of(_OUTCOME)
@@ -0,0 +1,51 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Worker entrypoint for the Hacker News top story Flow."""
4
+
5
+ import asyncio
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ import dex
11
+ from dex_connectors.sdk.projectconfig import load_from_environment
12
+
13
+ from dex_connectors.hackernews import new_project_connection
14
+
15
+ from .flow import CONNECTION_NAME, TopStoryFlow
16
+
17
+
18
+ async def run_worker() -> None:
19
+ """Load the named Dex Web connection and serve top story lookups."""
20
+ project = await load_from_environment()
21
+ connection = None
22
+ cache = None
23
+ try:
24
+ connection = new_project_connection(project, CONNECTION_NAME)
25
+ registry = dex.Registry([TopStoryFlow(connection)], allow_async_handlers=True)
26
+ cache_directory = os.environ.get(
27
+ "DEX_BLOB_CACHE_DIR", str(Path(tempfile.gettempdir()) / "dex-hackernews-top-story-blobs")
28
+ )
29
+ cache = dex.open_blob_cache(dex.BlobCacheConfig(cache_directory, 1 << 30))
30
+ worker = dex.AsyncWorker(
31
+ registry,
32
+ cache,
33
+ dex.WorkerOptions(server_address=os.environ.get("DEX_FLOW_SERVICE_ADDRESS", "localhost:8801")),
34
+ )
35
+ async with worker:
36
+ await worker.start()
37
+ finally:
38
+ if connection is not None:
39
+ await connection.aclose()
40
+ if cache is not None:
41
+ cache.close()
42
+ await project.close()
43
+
44
+
45
+ def main() -> None:
46
+ """Run until the Worker stops or the process receives an interrupt."""
47
+ asyncio.run(run_worker())
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main()
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "dex-connector-hackernews"
7
+ version = "0.1.0"
8
+ description = "Hacker News connector for Dex Flows"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ dependencies = ["dex-connectors-sdk==0.1.0", "dex-python-sdk>=1.28.0", "httpx>=0.27"]
13
+
14
+ [dependency-groups]
15
+ dev = ["pytest>=8", "pytest-asyncio>=0.24", "mypy>=1.13", "ruff>=0.7", "pyyaml>=6", "types-PyYAML>=6"]
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/dex_connectors"]
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
22
+ pythonpath = [".", "src"]
23
+ asyncio_mode = "auto"
24
+ asyncio_default_fixture_loop_scope = "function"
25
+
26
+ [tool.mypy]
27
+ python_version = "3.11"
28
+ strict = true
29
+ mypy_path = "src"
30
+ explicit_package_bases = true
31
+
32
+ [tool.ruff]
33
+ target-version = "py311"
34
+ line-length = 110
35
+
36
+ [tool.ruff.lint]
37
+ select = ["E", "F", "I", "UP", "B"]
@@ -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