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.
- dex_connector_hackernews-0.1.0/.gitignore +6 -0
- dex_connector_hackernews-0.1.0/PKG-INFO +47 -0
- dex_connector_hackernews-0.1.0/README.md +36 -0
- dex_connector_hackernews-0.1.0/examples/top_story/__init__.py +3 -0
- dex_connector_hackernews-0.1.0/examples/top_story/flow.py +133 -0
- dex_connector_hackernews-0.1.0/examples/top_story/worker.py +51 -0
- dex_connector_hackernews-0.1.0/pyproject.toml +37 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/__init__.py +50 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/_generated/__init__.py +2 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/_generated/config.py +15 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/_generated/definitions.py +57 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/_generated/models.py +98 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/_generated/steps.py +131 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/_generated/timestamps.py +60 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/client.py +357 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/connection.py +109 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/dex-connector.json +82 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/hackernewsmock/__init__.py +119 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/models.py +36 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/py.typed +0 -0
- dex_connector_hackernews-0.1.0/src/dex_connectors/hackernews/steps.py +35 -0
- dex_connector_hackernews-0.1.0/tests/test_conformance.py +48 -0
- dex_connector_hackernews-0.1.0/tests/test_manifest_contract.py +113 -0
- dex_connector_hackernews-0.1.0/tests/test_operations.py +146 -0
- dex_connector_hackernews-0.1.0/uv.lock +932 -0
|
@@ -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,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,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
|