flru-parser 0.3.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.
flru/state.py ADDED
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import sqlite3
7
+ from collections.abc import Iterable
8
+ from contextlib import closing
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Protocol, runtime_checkable
12
+
13
+ from .models import CrawlCheckpoint, ProjectRecord, ProjectSummary
14
+
15
+
16
+ def project_content_hash(project: ProjectSummary) -> str:
17
+ payload = project.model_dump(mode="json", exclude={"source"})
18
+ encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
19
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
20
+
21
+
22
+ def record_for(project: ProjectSummary, previous: ProjectRecord | None = None) -> ProjectRecord:
23
+ now = datetime.now(timezone.utc)
24
+ return ProjectRecord(
25
+ project=project,
26
+ first_seen_at=previous.first_seen_at if previous else now,
27
+ last_seen_at=now,
28
+ content_hash=project_content_hash(project),
29
+ source_updated_at=getattr(project, "updated_at", None),
30
+ )
31
+
32
+
33
+ @runtime_checkable
34
+ class CrawlStateStore(Protocol):
35
+ async def get(self, project_id: int) -> ProjectRecord | None: ...
36
+
37
+ async def contains(self, project_id: int) -> bool: ...
38
+
39
+ async def save(self, record: ProjectRecord) -> None: ...
40
+
41
+ async def save_many(self, records: Iterable[ProjectRecord]) -> None: ...
42
+
43
+ async def get_checkpoint(self, namespace: str) -> CrawlCheckpoint | None: ...
44
+
45
+ async def save_checkpoint(self, checkpoint: CrawlCheckpoint) -> None: ...
46
+
47
+ async def close(self) -> None: ...
48
+
49
+
50
+ class MemoryStateStore:
51
+ def __init__(self) -> None:
52
+ self.records: dict[int, ProjectRecord] = {}
53
+ self.checkpoints: dict[str, CrawlCheckpoint] = {}
54
+ self._lock = asyncio.Lock()
55
+
56
+ async def get(self, project_id: int) -> ProjectRecord | None:
57
+ async with self._lock:
58
+ return self.records.get(project_id)
59
+
60
+ async def contains(self, project_id: int) -> bool:
61
+ async with self._lock:
62
+ return project_id in self.records
63
+
64
+ async def save(self, record: ProjectRecord) -> None:
65
+ async with self._lock:
66
+ self.records[record.project.id] = record
67
+
68
+ async def save_many(self, records: Iterable[ProjectRecord]) -> None:
69
+ async with self._lock:
70
+ self.records.update({record.project.id: record for record in records})
71
+
72
+ async def get_checkpoint(self, namespace: str) -> CrawlCheckpoint | None:
73
+ async with self._lock:
74
+ return self.checkpoints.get(namespace)
75
+
76
+ async def save_checkpoint(self, checkpoint: CrawlCheckpoint) -> None:
77
+ async with self._lock:
78
+ self.checkpoints[checkpoint.namespace] = checkpoint
79
+
80
+ async def close(self) -> None:
81
+ return None
82
+
83
+
84
+ class SQLiteStateStore:
85
+ """Durable state store using only the Python standard library."""
86
+
87
+ def __init__(self, path: str | Path) -> None:
88
+ self.path = str(path)
89
+ self._lock = asyncio.Lock()
90
+ self._initialized = False
91
+
92
+ async def _ensure_schema(self) -> None:
93
+ if self._initialized:
94
+ return
95
+ async with self._lock:
96
+ if self._initialized:
97
+ return
98
+ await asyncio.to_thread(self._initialize_sync)
99
+ self._initialized = True
100
+
101
+ def _connect(self) -> sqlite3.Connection:
102
+ connection = sqlite3.connect(self.path, timeout=30)
103
+ connection.execute("PRAGMA journal_mode=WAL")
104
+ connection.execute("PRAGMA busy_timeout=30000")
105
+ return connection
106
+
107
+ def _initialize_sync(self) -> None:
108
+ with closing(self._connect()) as connection, connection:
109
+ connection.executescript(
110
+ """
111
+ CREATE TABLE IF NOT EXISTS projects (
112
+ project_id INTEGER PRIMARY KEY,
113
+ payload TEXT NOT NULL
114
+ );
115
+ CREATE TABLE IF NOT EXISTS checkpoints (
116
+ namespace TEXT PRIMARY KEY,
117
+ payload TEXT NOT NULL
118
+ );
119
+ """
120
+ )
121
+
122
+ async def get(self, project_id: int) -> ProjectRecord | None:
123
+ await self._ensure_schema()
124
+ return await asyncio.to_thread(self._get_sync, project_id)
125
+
126
+ def _get_sync(self, project_id: int) -> ProjectRecord | None:
127
+ with closing(self._connect()) as connection:
128
+ row = connection.execute(
129
+ "SELECT payload FROM projects WHERE project_id = ?", (project_id,)
130
+ ).fetchone()
131
+ return ProjectRecord.model_validate_json(row[0]) if row else None
132
+
133
+ async def contains(self, project_id: int) -> bool:
134
+ return await self.get(project_id) is not None
135
+
136
+ async def save(self, record: ProjectRecord) -> None:
137
+ await self.save_many([record])
138
+
139
+ async def save_many(self, records: Iterable[ProjectRecord]) -> None:
140
+ await self._ensure_schema()
141
+ values = [
142
+ (record.project.id, record.model_dump_json())
143
+ for record in records
144
+ ]
145
+ if values:
146
+ await asyncio.to_thread(self._save_many_sync, values)
147
+
148
+ def _save_many_sync(self, values: list[tuple[int, str]]) -> None:
149
+ with closing(self._connect()) as connection, connection:
150
+ connection.executemany(
151
+ "INSERT INTO projects(project_id, payload) VALUES(?, ?) "
152
+ "ON CONFLICT(project_id) DO UPDATE SET payload = excluded.payload",
153
+ values,
154
+ )
155
+
156
+ async def get_checkpoint(self, namespace: str) -> CrawlCheckpoint | None:
157
+ await self._ensure_schema()
158
+ return await asyncio.to_thread(self._get_checkpoint_sync, namespace)
159
+
160
+ def _get_checkpoint_sync(self, namespace: str) -> CrawlCheckpoint | None:
161
+ with closing(self._connect()) as connection:
162
+ row = connection.execute(
163
+ "SELECT payload FROM checkpoints WHERE namespace = ?", (namespace,)
164
+ ).fetchone()
165
+ return CrawlCheckpoint.model_validate_json(row[0]) if row else None
166
+
167
+ async def save_checkpoint(self, checkpoint: CrawlCheckpoint) -> None:
168
+ await self._ensure_schema()
169
+ await asyncio.to_thread(self._save_checkpoint_sync, checkpoint)
170
+
171
+ def _save_checkpoint_sync(self, checkpoint: CrawlCheckpoint) -> None:
172
+ with closing(self._connect()) as connection, connection:
173
+ connection.execute(
174
+ "INSERT INTO checkpoints(namespace, payload) VALUES(?, ?) "
175
+ "ON CONFLICT(namespace) DO UPDATE SET payload = excluded.payload",
176
+ (checkpoint.namespace, checkpoint.model_dump_json()),
177
+ )
178
+
179
+ async def close(self) -> None:
180
+ return None
181
+
182
+
183
+ class PostgresStateStore:
184
+ """Optional asyncpg-backed store. Install ``flru-parser[postgres]``."""
185
+
186
+ def __init__(self, dsn: str) -> None:
187
+ self.dsn = dsn
188
+ self._pool: Any | None = None
189
+ self._lock = asyncio.Lock()
190
+
191
+ async def _get_pool(self) -> Any:
192
+ if self._pool is not None:
193
+ return self._pool
194
+ async with self._lock:
195
+ if self._pool is None:
196
+ try:
197
+ import asyncpg
198
+ except ImportError as exc: # pragma: no cover - optional dependency
199
+ raise RuntimeError("Install flru-parser[postgres]") from exc
200
+ self._pool = await asyncpg.create_pool(self.dsn)
201
+ async with self._pool.acquire() as connection:
202
+ await connection.execute(
203
+ """
204
+ CREATE TABLE IF NOT EXISTS flru_projects (
205
+ project_id BIGINT PRIMARY KEY,
206
+ payload JSONB NOT NULL
207
+ );
208
+ CREATE TABLE IF NOT EXISTS flru_checkpoints (
209
+ namespace TEXT PRIMARY KEY,
210
+ payload JSONB NOT NULL
211
+ );
212
+ """
213
+ )
214
+ return self._pool
215
+
216
+ async def get(self, project_id: int) -> ProjectRecord | None:
217
+ pool = await self._get_pool()
218
+ async with pool.acquire() as connection:
219
+ value = await connection.fetchval(
220
+ "SELECT payload FROM flru_projects WHERE project_id=$1", project_id
221
+ )
222
+ if not value:
223
+ return None
224
+ return (
225
+ ProjectRecord.model_validate_json(value)
226
+ if isinstance(value, str)
227
+ else ProjectRecord.model_validate(value)
228
+ )
229
+
230
+ async def contains(self, project_id: int) -> bool:
231
+ return await self.get(project_id) is not None
232
+
233
+ async def save(self, record: ProjectRecord) -> None:
234
+ await self.save_many([record])
235
+
236
+ async def save_many(self, records: Iterable[ProjectRecord]) -> None:
237
+ values = [(record.project.id, record.model_dump_json()) for record in records]
238
+ if not values:
239
+ return
240
+ pool = await self._get_pool()
241
+ async with pool.acquire() as connection:
242
+ await connection.executemany(
243
+ "INSERT INTO flru_projects(project_id,payload) VALUES($1,$2::jsonb) "
244
+ "ON CONFLICT(project_id) DO UPDATE SET payload=excluded.payload",
245
+ values,
246
+ )
247
+
248
+ async def get_checkpoint(self, namespace: str) -> CrawlCheckpoint | None:
249
+ pool = await self._get_pool()
250
+ async with pool.acquire() as connection:
251
+ value = await connection.fetchval(
252
+ "SELECT payload FROM flru_checkpoints WHERE namespace=$1", namespace
253
+ )
254
+ if not value:
255
+ return None
256
+ return (
257
+ CrawlCheckpoint.model_validate_json(value)
258
+ if isinstance(value, str)
259
+ else CrawlCheckpoint.model_validate(value)
260
+ )
261
+
262
+ async def save_checkpoint(self, checkpoint: CrawlCheckpoint) -> None:
263
+ pool = await self._get_pool()
264
+ async with pool.acquire() as connection:
265
+ await connection.execute(
266
+ "INSERT INTO flru_checkpoints(namespace,payload) VALUES($1,$2::jsonb) "
267
+ "ON CONFLICT(namespace) DO UPDATE SET payload=excluded.payload",
268
+ checkpoint.namespace,
269
+ checkpoint.model_dump_json(),
270
+ )
271
+
272
+ async def close(self) -> None:
273
+ if self._pool is not None:
274
+ await self._pool.close()
275
+ self._pool = None
276
+
277
+
278
+ class RedisStateStore:
279
+ """Optional Redis-backed store. Install ``flru-parser[redis]``."""
280
+
281
+ def __init__(self, url: str, *, prefix: str = "flru") -> None:
282
+ try:
283
+ from redis.asyncio import Redis
284
+ except ImportError as exc: # pragma: no cover - optional dependency
285
+ raise RuntimeError("Install flru-parser[redis]") from exc
286
+ self._redis = Redis.from_url(url, decode_responses=True)
287
+ self.prefix = prefix
288
+
289
+ def _project_key(self, project_id: int) -> str:
290
+ return f"{self.prefix}:project:{project_id}"
291
+
292
+ def _checkpoint_key(self, namespace: str) -> str:
293
+ return f"{self.prefix}:checkpoint:{namespace}"
294
+
295
+ async def get(self, project_id: int) -> ProjectRecord | None:
296
+ value = await self._redis.get(self._project_key(project_id))
297
+ return ProjectRecord.model_validate_json(value) if value else None
298
+
299
+ async def contains(self, project_id: int) -> bool:
300
+ return bool(await self._redis.exists(self._project_key(project_id)))
301
+
302
+ async def save(self, record: ProjectRecord) -> None:
303
+ await self._redis.set(self._project_key(record.project.id), record.model_dump_json())
304
+
305
+ async def save_many(self, records: Iterable[ProjectRecord]) -> None:
306
+ pipeline = self._redis.pipeline()
307
+ count = 0
308
+ for record in records:
309
+ pipeline.set(self._project_key(record.project.id), record.model_dump_json())
310
+ count += 1
311
+ if count:
312
+ await pipeline.execute()
313
+
314
+ async def get_checkpoint(self, namespace: str) -> CrawlCheckpoint | None:
315
+ value = await self._redis.get(self._checkpoint_key(namespace))
316
+ return CrawlCheckpoint.model_validate_json(value) if value else None
317
+
318
+ async def save_checkpoint(self, checkpoint: CrawlCheckpoint) -> None:
319
+ await self._redis.set(
320
+ self._checkpoint_key(checkpoint.namespace), checkpoint.model_dump_json()
321
+ )
322
+
323
+ async def close(self) -> None:
324
+ await self._redis.aclose()
flru/sync.py ADDED
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import threading
5
+ from collections.abc import Coroutine, Mapping
6
+ from pathlib import Path
7
+ from typing import Any, TypeVar
8
+
9
+ import httpx
10
+
11
+ from .config import ClientConfig
12
+ from .easy import Client as AsyncClient
13
+ from .easy import CookiesInput, ProxyInput
14
+ from .filters import ProjectFilters
15
+ from .models import FreelancerSummary, PageData, ProjectDetail, ProjectSummary, RequestMetrics, UserProfile
16
+ from .observability import EventHandler
17
+ from .state import CrawlStateStore
18
+
19
+ T = TypeVar("T")
20
+
21
+
22
+ class _LoopThread:
23
+ """Own one event loop in a dedicated thread for the sync facade."""
24
+
25
+ def __init__(self) -> None:
26
+ self._ready = threading.Event()
27
+ self._loop: asyncio.AbstractEventLoop | None = None
28
+ self._thread = threading.Thread(target=self._main, name="flru-sync", daemon=True)
29
+ self._thread.start()
30
+ self._ready.wait()
31
+
32
+ def _main(self) -> None:
33
+ loop = asyncio.new_event_loop()
34
+ asyncio.set_event_loop(loop)
35
+ self._loop = loop
36
+ self._ready.set()
37
+ try:
38
+ loop.run_forever()
39
+ finally:
40
+ pending = asyncio.all_tasks(loop)
41
+ for task in pending:
42
+ task.cancel()
43
+ if pending:
44
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
45
+ loop.run_until_complete(loop.shutdown_asyncgens())
46
+ loop.close()
47
+
48
+ def run(self, coroutine: Coroutine[Any, Any, T]) -> T:
49
+ if self._loop is None:
50
+ raise RuntimeError("Sync client loop is not available")
51
+ return asyncio.run_coroutine_threadsafe(coroutine, self._loop).result()
52
+
53
+ def close(self) -> None:
54
+ if self._loop is None:
55
+ return
56
+ self._loop.call_soon_threadsafe(self._loop.stop)
57
+ self._thread.join()
58
+ self._loop = None
59
+
60
+
61
+ class Client:
62
+ """Synchronous facade with the same simple API as :class:`flru.Client`."""
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ concurrency: int | None = None,
68
+ rps: float | None = None,
69
+ retries: int | None = None,
70
+ timeout: float | None = None,
71
+ proxy: ProxyInput = None,
72
+ cookies: CookiesInput = None,
73
+ strict: bool | None = None,
74
+ respect_robots_txt: bool | None = None,
75
+ http2: bool | None = None,
76
+ verify_ssl: bool | None = None,
77
+ config: ClientConfig | None = None,
78
+ event_handler: EventHandler | None = None,
79
+ transport: httpx.AsyncBaseTransport | None = None,
80
+ ) -> None:
81
+ self._loop_thread = _LoopThread()
82
+ try:
83
+ self._client = AsyncClient(
84
+ concurrency=concurrency,
85
+ rps=rps,
86
+ retries=retries,
87
+ timeout=timeout,
88
+ proxy=proxy,
89
+ cookies=cookies,
90
+ strict=strict,
91
+ respect_robots_txt=respect_robots_txt,
92
+ http2=http2,
93
+ verify_ssl=verify_ssl,
94
+ config=config,
95
+ event_handler=event_handler,
96
+ transport=transport,
97
+ )
98
+ except BaseException:
99
+ self._loop_thread.close()
100
+ raise
101
+ self._closed = False
102
+
103
+ def __enter__(self) -> Client:
104
+ return self
105
+
106
+ def __exit__(self, *_: object) -> None:
107
+ self.close()
108
+
109
+ def projects(self, **kwargs: Any) -> list[ProjectSummary] | list[ProjectDetail]:
110
+ return self._loop_thread.run(self._client.projects(**kwargs))
111
+
112
+ def project(self, project: int | str) -> ProjectDetail:
113
+ return self._loop_thread.run(self._client.project(project))
114
+
115
+ def freelancers(self, **kwargs: Any) -> list[FreelancerSummary]:
116
+ return self._loop_thread.run(self._client.freelancers(**kwargs))
117
+
118
+ def user(self, username: str, **kwargs: Any) -> UserProfile:
119
+ return self._loop_thread.run(self._client.user(username, **kwargs))
120
+
121
+ def new_projects(
122
+ self,
123
+ state: CrawlStateStore | str | Path = ".flru-state.db",
124
+ **kwargs: Any,
125
+ ) -> list[ProjectSummary]:
126
+ return self._loop_thread.run(self._client.new_projects(state, **kwargs))
127
+
128
+ def page(self, url: str) -> PageData:
129
+ return self._loop_thread.run(self._client.page(url))
130
+
131
+ def stats(self) -> RequestMetrics:
132
+ return self._loop_thread.run(self._client.stats())
133
+
134
+ # Compatibility methods from 0.2.x.
135
+ def get_projects(
136
+ self,
137
+ page: int = 1,
138
+ *,
139
+ category: str | None = None,
140
+ filters: ProjectFilters | None = None,
141
+ params: Mapping[str, Any] | None = None,
142
+ ) -> list[ProjectSummary]:
143
+ return self._loop_thread.run(
144
+ self._client.get_projects(
145
+ page,
146
+ category=category,
147
+ filters=filters,
148
+ params=params,
149
+ )
150
+ )
151
+
152
+ def crawl_projects(self, **kwargs: Any) -> list[ProjectSummary]:
153
+ return self._loop_thread.run(self._client.crawl_projects(**kwargs))
154
+
155
+ def get_project(self, project: int | str) -> ProjectDetail:
156
+ return self.project(project)
157
+
158
+ def get_user(self, user: str, **kwargs: Any) -> UserProfile:
159
+ return self._loop_thread.run(self._client.get_user(user, **kwargs))
160
+
161
+ def close(self) -> None:
162
+ if self._closed:
163
+ return
164
+ self._closed = True
165
+ try:
166
+ self._loop_thread.run(self._client.close())
167
+ finally:
168
+ self._loop_thread.close()
169
+
170
+
171
+ FLClient = Client
172
+
173
+
174
+ def projects(
175
+ *, client_options: dict[str, Any] | None = None, **kwargs: Any
176
+ ) -> list[ProjectSummary] | list[ProjectDetail]:
177
+ """One-shot synchronous project lookup."""
178
+ with Client(**(client_options or {})) as client:
179
+ return client.projects(**kwargs)
180
+
181
+
182
+ def project(
183
+ project_id_or_url: int | str,
184
+ *,
185
+ client_options: dict[str, Any] | None = None,
186
+ ) -> ProjectDetail:
187
+ """One-shot synchronous project lookup by ID or URL."""
188
+ with Client(**(client_options or {})) as client:
189
+ return client.project(project_id_or_url)
190
+
191
+
192
+ def freelancers(
193
+ *, client_options: dict[str, Any] | None = None, **kwargs: Any
194
+ ) -> list[FreelancerSummary]:
195
+ """One-shot synchronous freelancer lookup."""
196
+ with Client(**(client_options or {})) as client:
197
+ return client.freelancers(**kwargs)
198
+
199
+
200
+ def user(
201
+ username: str,
202
+ *,
203
+ client_options: dict[str, Any] | None = None,
204
+ **kwargs: Any,
205
+ ) -> UserProfile:
206
+ """One-shot synchronous user lookup."""
207
+ with Client(**(client_options or {})) as client:
208
+ return client.user(username, **kwargs)
209
+
210
+
211
+ __all__ = [
212
+ "Client",
213
+ "FLClient",
214
+ "freelancers",
215
+ "project",
216
+ "projects",
217
+ "user",
218
+ ]