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/__init__.py +138 -0
- flru/batch.py +28 -0
- flru/canary.py +33 -0
- flru/client.py +771 -0
- flru/config.py +114 -0
- flru/cookies.py +15 -0
- flru/easy.py +514 -0
- flru/exceptions.py +60 -0
- flru/filters.py +38 -0
- flru/integrations/__init__.py +1 -0
- flru/integrations/opentelemetry.py +29 -0
- flru/integrations/prometheus.py +35 -0
- flru/models.py +297 -0
- flru/observability.py +118 -0
- flru/parsers/__init__.py +13 -0
- flru/parsers/common.py +227 -0
- flru/parsers/freelancers.py +97 -0
- flru/parsers/projects.py +281 -0
- flru/parsers/users.py +193 -0
- flru/proxy.py +87 -0
- flru/py.typed +0 -0
- flru/resilience.py +157 -0
- flru/robots.py +45 -0
- flru/security.py +41 -0
- flru/state.py +324 -0
- flru/sync.py +218 -0
- flru/transport.py +362 -0
- flru_parser-0.3.0.dist-info/METADATA +421 -0
- flru_parser-0.3.0.dist-info/RECORD +33 -0
- flru_parser-0.3.0.dist-info/WHEEL +5 -0
- flru_parser-0.3.0.dist-info/entry_points.txt +2 -0
- flru_parser-0.3.0.dist-info/licenses/LICENSE +21 -0
- flru_parser-0.3.0.dist-info/top_level.txt +1 -0
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
|
+
]
|