opencraw 0.0.2__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.
@@ -0,0 +1,70 @@
1
+ # See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
2
+
3
+ # compiled output
4
+ dist
5
+ tmp
6
+ out-tsc
7
+
8
+ # dependencies
9
+ node_modules
10
+
11
+ # IDEs and editors
12
+ /.idea
13
+ .project
14
+ .classpath
15
+ .c9/
16
+ *.launch
17
+ .settings/
18
+ *.sublime-workspace
19
+
20
+ # IDE - VSCode
21
+ .vscode/*
22
+ !.vscode/settings.json
23
+ !.vscode/tasks.json
24
+ !.vscode/launch.json
25
+ !.vscode/extensions.json
26
+
27
+ # misc
28
+ /.sass-cache
29
+ /connect.lock
30
+ /coverage
31
+ /libpeerconnection.log
32
+ npm-debug.log
33
+ yarn-error.log
34
+ testem.log
35
+ /typings
36
+
37
+ # System Files
38
+ .DS_Store
39
+ Thumbs.db
40
+
41
+ .nx/cache
42
+ .nx/workspace-data
43
+ .cursor/rules/nx-rules.mdc
44
+ .github/instructions/nx.instructions.md
45
+
46
+ .claude/worktrees
47
+ .claude/settings.local.json
48
+ .nx/polygraph
49
+ .nx/self-healing
50
+ .nx/migrate-runs
51
+
52
+ # Added by MoNecromanCI: `npm run format`/`lint` run `eslint --cache`.
53
+ .eslintcache
54
+
55
+ test-output
56
+
57
+ # persistent browser profiles (logins, cookies): never commit
58
+ .opencraw/
59
+
60
+ vite.config.*.timestamp*
61
+
62
+ # MoNecromanCI: keep the committed per-environment Vite config (public VITE_ values)
63
+ !apps/*/.env.dev
64
+ !apps/*/.env.uat
65
+ !apps/*/.env.prod
66
+
67
+ # Added by MoNecromanCI: Python bytecode. The tool caches (.mypy_cache,
68
+ # .pytest_cache, .ruff_cache) write their own .gitignore; CPython does not.
69
+ __pycache__/
70
+ *.py[cod]
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.5
2
+ Name: opencraw
3
+ Version: 0.0.2
4
+ Summary: Build OpenCraw recipes in Python, drive the real OpenCraw CLI, and write hooks in Python.
5
+ Project-URL: Homepage, https://github.com/Open-Craw/OpenCraw
6
+ Project-URL: Repository, https://github.com/Open-Craw/OpenCraw
7
+ Project-URL: Issues, https://github.com/Open-Craw/OpenCraw/issues
8
+ License-Expression: MIT
9
+ Keywords: crawler,opencraw,recipes,scraping
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
13
+ # opencraw (Python)
14
+
15
+ Build [OpenCraw](https://github.com/Open-Craw/OpenCraw) recipes in Python, and drive the real CLI. Standard library only; it
16
+ needs Python 3.12+ and, to validate or run, the `opencraw` CLI (`npm i -g @opencraw/cli`, or
17
+ `OPENCRAW_CLI` set to the command that starts it; with neither, `npx @opencraw/cli` is used).
18
+
19
+ A recipe stays a plain dict in the engine's own shape, so the recipe guide
20
+ ([docs/recipes/authoring.md](https://github.com/Open-Craw/OpenCraw/blob/main/docs/recipes/authoring.md)) is the reference for every key. The constructors only save typing, and
21
+ the engine, not this package, says what is valid.
22
+
23
+ ```python
24
+ from opencraw import input_recipe, output_recipe, run, step, validate
25
+
26
+ product = output_recipe(
27
+ "product",
28
+ {
29
+ "url": {"type": "url", "required": True, "key": True},
30
+ "title": {"type": "string", "required": True},
31
+ },
32
+ )
33
+
34
+ shops = [
35
+ input_recipe(
36
+ f"shop-{name}",
37
+ "product",
38
+ "web",
39
+ [url],
40
+ [
41
+ step("goto", url="{{start.url}}"),
42
+ step("extract", id="title", selector=selector, kind="css", take="text"),
43
+ step("emit"),
44
+ ],
45
+ {"url": {"from": "start.url"}, "title": {"from": "title"}},
46
+ )
47
+ for name, url, selector in [
48
+ ("a", "https://a.example", "h1"),
49
+ ("b", "https://b.example", ".name"),
50
+ ]
51
+ ]
52
+
53
+ result = validate(
54
+ [product, *shops]
55
+ ) # Validation(ok, output): problems are data, not exceptions
56
+ records = run(
57
+ [product, *shops], "--only", "shop-a"
58
+ ) # records as dicts; OpenCrawError if the crawl fails
59
+ ```
60
+
61
+ Reserved words: pass a step's `as` or `from` through `**{"as": "json"}`.
62
+
63
+ `write_recipes(folder, recipes)` writes `<id>.input.json` and `<id>.output.json` for the CLI, the Studio
64
+ and the MCP server.
65
+
66
+ ## A hook written in Python
67
+
68
+ A recipe's hook (`{ "op": "hook", "name": "slug" }`) can be a Python script. The hooks module registers it
69
+ with `commandHook` (`@opencraw/core`), and the script is a few lines:
70
+
71
+ ```python
72
+ # hooks/slug.py
73
+ import re
74
+ from opencraw import serve_hook
75
+
76
+ serve_hook(
77
+ lambda value, args, request: re.sub(r"[^a-z0-9]+", "-", str(value).lower()).strip(
78
+ "-"
79
+ )
80
+ )
81
+ ```
82
+
83
+ `serve_hook(handler)` reads the request from stdin, calls `handler(input, args, request)` (`input` is `None`
84
+ for a `hook` step; `request` carries `context` and `idempotencyKey`) and writes the answer to stdout. What the
85
+ handler returns is the output; an exception becomes an error answer with its message, so the step fails with
86
+ that reason. A handler whose work takes longer raises `HookPending(retry_after_ms)`: OpenCraw runs the script
87
+ again after that wait, with the same `idempotencyKey`, until it returns or the hook's `maxWaitMs` passes, so the
88
+ script starts the work once and looks it up by that key on later runs. The request and answer shapes are in
89
+ `packages/core/schemas/callout-*.schema.json`.
90
+
91
+ Not in this package: a Python engine and generated typed models. The crawl itself always runs in the
92
+ OpenCraw CLI. It is published to PyPI as `opencraw`: `mnci ci release` versions and publishes what sits
93
+ under `python-packages/`.
94
+
95
+ ## Development
96
+
97
+ `nx test opencraw` (pytest; the CLI integration tests use this repository's built CLI and skip without it),
98
+ `nx lint opencraw` (Ruff), `nx typecheck opencraw` (mypy strict).
@@ -0,0 +1,86 @@
1
+ # opencraw (Python)
2
+
3
+ Build [OpenCraw](https://github.com/Open-Craw/OpenCraw) recipes in Python, and drive the real CLI. Standard library only; it
4
+ needs Python 3.12+ and, to validate or run, the `opencraw` CLI (`npm i -g @opencraw/cli`, or
5
+ `OPENCRAW_CLI` set to the command that starts it; with neither, `npx @opencraw/cli` is used).
6
+
7
+ A recipe stays a plain dict in the engine's own shape, so the recipe guide
8
+ ([docs/recipes/authoring.md](https://github.com/Open-Craw/OpenCraw/blob/main/docs/recipes/authoring.md)) is the reference for every key. The constructors only save typing, and
9
+ the engine, not this package, says what is valid.
10
+
11
+ ```python
12
+ from opencraw import input_recipe, output_recipe, run, step, validate
13
+
14
+ product = output_recipe(
15
+ "product",
16
+ {
17
+ "url": {"type": "url", "required": True, "key": True},
18
+ "title": {"type": "string", "required": True},
19
+ },
20
+ )
21
+
22
+ shops = [
23
+ input_recipe(
24
+ f"shop-{name}",
25
+ "product",
26
+ "web",
27
+ [url],
28
+ [
29
+ step("goto", url="{{start.url}}"),
30
+ step("extract", id="title", selector=selector, kind="css", take="text"),
31
+ step("emit"),
32
+ ],
33
+ {"url": {"from": "start.url"}, "title": {"from": "title"}},
34
+ )
35
+ for name, url, selector in [
36
+ ("a", "https://a.example", "h1"),
37
+ ("b", "https://b.example", ".name"),
38
+ ]
39
+ ]
40
+
41
+ result = validate(
42
+ [product, *shops]
43
+ ) # Validation(ok, output): problems are data, not exceptions
44
+ records = run(
45
+ [product, *shops], "--only", "shop-a"
46
+ ) # records as dicts; OpenCrawError if the crawl fails
47
+ ```
48
+
49
+ Reserved words: pass a step's `as` or `from` through `**{"as": "json"}`.
50
+
51
+ `write_recipes(folder, recipes)` writes `<id>.input.json` and `<id>.output.json` for the CLI, the Studio
52
+ and the MCP server.
53
+
54
+ ## A hook written in Python
55
+
56
+ A recipe's hook (`{ "op": "hook", "name": "slug" }`) can be a Python script. The hooks module registers it
57
+ with `commandHook` (`@opencraw/core`), and the script is a few lines:
58
+
59
+ ```python
60
+ # hooks/slug.py
61
+ import re
62
+ from opencraw import serve_hook
63
+
64
+ serve_hook(
65
+ lambda value, args, request: re.sub(r"[^a-z0-9]+", "-", str(value).lower()).strip(
66
+ "-"
67
+ )
68
+ )
69
+ ```
70
+
71
+ `serve_hook(handler)` reads the request from stdin, calls `handler(input, args, request)` (`input` is `None`
72
+ for a `hook` step; `request` carries `context` and `idempotencyKey`) and writes the answer to stdout. What the
73
+ handler returns is the output; an exception becomes an error answer with its message, so the step fails with
74
+ that reason. A handler whose work takes longer raises `HookPending(retry_after_ms)`: OpenCraw runs the script
75
+ again after that wait, with the same `idempotencyKey`, until it returns or the hook's `maxWaitMs` passes, so the
76
+ script starts the work once and looks it up by that key on later runs. The request and answer shapes are in
77
+ `packages/core/schemas/callout-*.schema.json`.
78
+
79
+ Not in this package: a Python engine and generated typed models. The crawl itself always runs in the
80
+ OpenCraw CLI. It is published to PyPI as `opencraw`: `mnci ci release` versions and publishes what sits
81
+ under `python-packages/`.
82
+
83
+ ## Development
84
+
85
+ `nx test opencraw` (pytest; the CLI integration tests use this repository's built CLI and skip without it),
86
+ `nx lint opencraw` (Ruff), `nx typecheck opencraw` (mypy strict).
@@ -0,0 +1,33 @@
1
+ """Build OpenCraw recipes in Python, drive the real CLI, and write hooks. See README.md."""
2
+
3
+ from .build_recipe_algorithm import input_recipe, output_recipe, step
4
+ from .hook_pending_error import HookPending
5
+ from .opencraw_cli_client import cli_command, run, validate
6
+ from .opencraw_contract import (
7
+ HookContext,
8
+ HookRequest,
9
+ Recipe,
10
+ RecipeSource,
11
+ Validation,
12
+ )
13
+ from .opencraw_error import OpenCrawError
14
+ from .serve_hook_use_case import serve_hook
15
+ from .write_recipes_use_case import write_recipes
16
+
17
+ __all__ = [
18
+ "HookContext",
19
+ "HookPending",
20
+ "HookRequest",
21
+ "OpenCrawError",
22
+ "Recipe",
23
+ "RecipeSource",
24
+ "Validation",
25
+ "cli_command",
26
+ "input_recipe",
27
+ "output_recipe",
28
+ "run",
29
+ "serve_hook",
30
+ "step",
31
+ "validate",
32
+ "write_recipes",
33
+ ]
@@ -0,0 +1,52 @@
1
+ """Plain-dict constructors for recipes. They save typing and nothing more: the step and field shapes are
2
+ the engine's, so a key goes in exactly as the recipe guide spells it (``forEach``, ``inStock``) and the
3
+ engine, not this module, says whether it is valid."""
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Any
7
+
8
+ from .opencraw_contract import Recipe
9
+
10
+ StartPoint = str | Mapping[str, Any]
11
+
12
+
13
+ def step(step_type: str, **fields: Any) -> Recipe:
14
+ """One step: ``step('goto', url='{{start.url}}')`` is ``{'type': 'goto', 'url': '{{start.url}}'}``."""
15
+ return {"type": step_type, **fields}
16
+
17
+
18
+ def output_recipe(
19
+ recipe_id: str, fields: Mapping[str, Any], version: int = 1, **extra: Any
20
+ ) -> Recipe:
21
+ """An output recipe: the records' schema."""
22
+ return {
23
+ "kind": "output",
24
+ "id": recipe_id,
25
+ "version": version,
26
+ "fields": dict(fields),
27
+ **extra,
28
+ }
29
+
30
+
31
+ def input_recipe(
32
+ recipe_id: str,
33
+ output: str,
34
+ mode: str,
35
+ start: Sequence[StartPoint],
36
+ steps: Sequence[Mapping[str, Any]],
37
+ mapping: Mapping[str, Any] | None = None,
38
+ **extra: Any,
39
+ ) -> Recipe:
40
+ """An input recipe. A start given as a string is a URL: ``'https://a.example'`` is ``{'url': ...}``."""
41
+ return {
42
+ "kind": "input",
43
+ "id": recipe_id,
44
+ "output": output,
45
+ "mode": mode,
46
+ "start": [
47
+ {"url": point} if isinstance(point, str) else dict(point) for point in start
48
+ ],
49
+ "steps": [dict(each) for each in steps],
50
+ "mapping": dict(mapping or {}),
51
+ **extra,
52
+ }
@@ -0,0 +1,12 @@
1
+ class HookPending(Exception):
2
+ """Raised by a hook handler whose work is not done yet.
3
+
4
+ ``serve_hook`` turns it into a ``pending`` answer: OpenCraw runs the hook again, after
5
+ ``retry_after_ms`` (a second when it is ``None``), with the same ``idempotencyKey``, until the
6
+ handler returns or the hook's ``maxWaitMs`` passes. Start the work the first time, and look it up
7
+ by ``request["idempotencyKey"]`` on the later calls.
8
+ """
9
+
10
+ def __init__(self, retry_after_ms: int | None = None) -> None:
11
+ super().__init__("pending")
12
+ self.retry_after_ms = retry_after_ms
@@ -0,0 +1,82 @@
1
+ import json
2
+ import os
3
+ import shlex
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ from collections.abc import Mapping, Sequence
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .opencraw_contract import RecipeSource, Validation
12
+ from .opencraw_error import OpenCrawError
13
+ from .write_recipes_use_case import write_recipes
14
+
15
+
16
+ def cli_command() -> list[str]:
17
+ """How to start the CLI: ``OPENCRAW_CLI`` if set, else ``opencraw`` on the PATH, else ``npx``."""
18
+ configured = os.environ.get("OPENCRAW_CLI")
19
+ if configured:
20
+ return shlex.split(configured)
21
+ installed = shutil.which("opencraw")
22
+ if installed:
23
+ return [installed]
24
+ npx = shutil.which("npx")
25
+ if npx is None:
26
+ raise OpenCrawError(
27
+ "opencraw is not installed: run `npm i -g @opencraw/cli` or set OPENCRAW_CLI"
28
+ )
29
+ return [npx, "--yes", "@opencraw/cli"]
30
+
31
+
32
+ def validate(recipes: Sequence[RecipeSource]) -> Validation:
33
+ """Runs ``opencraw validate``. The result is not an exception when the recipes are wrong: ``ok`` says so
34
+ and ``output`` lists every problem with its path."""
35
+ with _RecipePaths(recipes) as paths:
36
+ done = _call("validate", *paths)
37
+ return Validation(
38
+ ok=done.returncode == 0, output=(done.stdout + done.stderr).strip()
39
+ )
40
+
41
+
42
+ def run(recipes: Sequence[RecipeSource], *options: str) -> list[dict[str, Any]]:
43
+ """Runs ``opencraw run`` and returns the records it printed as dicts. ``options`` are CLI flags, such as
44
+ ``'--only', 'shop-a'``. Raises ``OpenCrawError`` with the CLI's message when the crawl fails."""
45
+ with _RecipePaths(recipes) as paths:
46
+ done = _call("run", *paths, *options)
47
+ if done.returncode != 0:
48
+ raise OpenCrawError((done.stderr or done.stdout).strip())
49
+ return [
50
+ json.loads(line)
51
+ for line in done.stdout.splitlines()
52
+ if line.strip().startswith("{")
53
+ ]
54
+
55
+
56
+ class _RecipePaths:
57
+ """Recipe paths for the CLI: dicts are written to a temporary folder that goes away afterwards."""
58
+
59
+ def __init__(self, recipes: Sequence[RecipeSource]) -> None:
60
+ self._recipes = recipes
61
+ self._folder = tempfile.TemporaryDirectory(prefix="opencraw-")
62
+
63
+ def __enter__(self) -> list[str]:
64
+ paths = [str(item) for item in self._recipes if not isinstance(item, Mapping)]
65
+ objects = [item for item in self._recipes if isinstance(item, Mapping)]
66
+ if objects:
67
+ paths.append(str(Path(self._folder.name)))
68
+ write_recipes(self._folder.name, objects)
69
+ return paths
70
+
71
+ def __exit__(self, *_: object) -> None:
72
+ self._folder.cleanup()
73
+
74
+
75
+ def _call(*arguments: str) -> subprocess.CompletedProcess[str]:
76
+ return subprocess.run(
77
+ [*cli_command(), *arguments],
78
+ capture_output=True,
79
+ text=True,
80
+ encoding="utf-8",
81
+ check=False,
82
+ )
@@ -0,0 +1,36 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Any, NotRequired, TypedDict
5
+
6
+ # A recipe as a plain dict, in the engine's own shape.
7
+ Recipe = dict[str, Any]
8
+
9
+ # A recipe given to validate() or run(): a file or folder path, or the recipe as a dict.
10
+ RecipeSource = str | Path | Mapping[str, Any]
11
+
12
+
13
+ class HookContext(TypedDict):
14
+ """Where a hook runs: the recipe, and the extracted values visible at the call site."""
15
+
16
+ recipeId: str
17
+ scope: dict[str, Any]
18
+
19
+
20
+ class HookRequest(TypedDict):
21
+ """The request OpenCraw sends a hook (``schemas/callout-request.schema.json``)."""
22
+
23
+ kind: str
24
+ name: str
25
+ input: NotRequired[Any]
26
+ args: dict[str, Any]
27
+ context: HookContext
28
+ idempotencyKey: str
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Validation:
33
+ """What ``opencraw validate`` said: ``ok``, and ``output`` listing every problem with its path."""
34
+
35
+ ok: bool
36
+ output: str
@@ -0,0 +1,2 @@
1
+ class OpenCrawError(RuntimeError):
2
+ """The CLI exited with an error; the message is what it printed."""
File without changes
@@ -0,0 +1,40 @@
1
+ import json
2
+ import sys
3
+ from collections.abc import Callable
4
+ from typing import Any, TextIO
5
+
6
+ from .hook_pending_error import HookPending
7
+ from .opencraw_contract import HookRequest
8
+
9
+
10
+ def serve_hook(
11
+ handler: Callable[[Any, dict[str, Any], HookRequest], Any],
12
+ stdin: TextIO | None = None,
13
+ stdout: TextIO | None = None,
14
+ ) -> None:
15
+ """Answer one OpenCraw hook call: read the request, run ``handler``, write the answer.
16
+
17
+ ``handler(input, args, request)`` gets the value a hook transform is working on (``None`` for a
18
+ ``hook`` step), the recipe's arguments, and the whole request (``context``, ``idempotencyKey``).
19
+ What it returns is the output, and an exception becomes an ``error`` answer with its message, so
20
+ the step fails with that reason instead of a stack trace. Raising ``HookPending`` answers
21
+ ``pending`` instead, and OpenCraw asks again later. Use it as the whole of a hook script,
22
+ the program ``commandHook`` runs::
23
+
24
+ from opencraw import serve_hook
25
+
26
+ serve_hook(lambda value, args, request: str(value).strip().lower())
27
+ """
28
+ request: HookRequest = json.load(stdin or sys.stdin)
29
+ try:
30
+ answer: dict[str, Any] = {
31
+ "status": "ok",
32
+ "output": handler(request.get("input"), request.get("args", {}), request),
33
+ }
34
+ except HookPending as pending:
35
+ answer = {"status": "pending"}
36
+ if pending.retry_after_ms is not None:
37
+ answer["retryAfterMs"] = pending.retry_after_ms
38
+ except Exception as error: # noqa: BLE001 - whatever the handler raises is the hook's answer
39
+ answer = {"status": "error", "error": str(error) or type(error).__name__}
40
+ json.dump(answer, stdout or sys.stdout)
@@ -0,0 +1,24 @@
1
+ import json
2
+ from collections.abc import Iterable, Mapping
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+
7
+ def write_recipes(
8
+ folder: str | Path, recipes: Iterable[Mapping[str, Any]]
9
+ ) -> list[Path]:
10
+ """Writes each recipe as ``<id>.input.json`` or ``<id>.output.json``, the layout the CLI, the Studio
11
+ and the MCP server read. The folder is created when missing. Returns the files written."""
12
+ target = Path(folder)
13
+ target.mkdir(parents=True, exist_ok=True)
14
+ written = []
15
+ for recipe in recipes:
16
+ kind = recipe.get("kind")
17
+ if kind not in ("input", "output") or not isinstance(recipe.get("id"), str):
18
+ raise ValueError(
19
+ f"a recipe needs a kind of 'input' or 'output' and a string id, got {dict(recipe)!r}"
20
+ )
21
+ path = target / f"{recipe['id']}.{kind}.json"
22
+ path.write_text(json.dumps(recipe, indent=2) + "\n", encoding="utf-8")
23
+ written.append(path)
24
+ return written
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "opencraw",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "projectType": "library",
5
+ "sourceRoot": "python-packages/opencraw",
6
+ "targets": {
7
+ "lint": {
8
+ "executor": "@mnci/nx-python-pip:lint",
9
+ "options": {}
10
+ },
11
+ "test": {
12
+ "executor": "@mnci/nx-python-pip:test",
13
+ "options": {},
14
+ "dependsOn": [
15
+ "^build"
16
+ ]
17
+ },
18
+ "typecheck": {
19
+ "executor": "@mnci/nx-python-pip:typecheck",
20
+ "options": {}
21
+ },
22
+ "build": {
23
+ "executor": "@mnci/nx-python-pip:build",
24
+ "outputs": [
25
+ "{projectRoot}/dist"
26
+ ],
27
+ "options": {}
28
+ },
29
+ "nx-release-publish": {
30
+ "executor": "@mnci/nx-python-pip:publish",
31
+ "dependsOn": [
32
+ "build"
33
+ ],
34
+ "options": {}
35
+ }
36
+ },
37
+ "implicitDependencies": [
38
+ "@opencraw/cli"
39
+ ],
40
+ "release": {
41
+ "version": {
42
+ "versionActions": "@mnci/nx-python-pip/release/version-actions"
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "opencraw"
3
+ version = "0.0.2"
4
+ description = "Build OpenCraw recipes in Python, drive the real OpenCraw CLI, and write hooks in Python."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ keywords = ["opencraw", "crawler", "scraping", "recipes"]
8
+ requires-python = ">=3.12"
9
+ dependencies = []
10
+
11
+ [project.urls]
12
+ Homepage = "https://github.com/Open-Craw/OpenCraw"
13
+ Repository = "https://github.com/Open-Craw/OpenCraw"
14
+ Issues = "https://github.com/Open-Craw/OpenCraw/issues"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["opencraw"]
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+
26
+ [tool.mypy]
27
+ strict = true
28
+ # Stub-less third-party packages, and ONLY those. Measured on a generated
29
+ # project: plain strict passes out of the box, and the one thing that breaks it
30
+ # in practice is importing a library that ships no type stubs - "import yaml"
31
+ # fails as import-untyped on code you wrote normally and cannot fix. Disabling
32
+ # that one code keeps every other strict check on your own code, and a module
33
+ # that genuinely cannot be found still fails, as import-not-found.
34
+ disable_error_code = ["import-untyped"]
@@ -0,0 +1,39 @@
1
+ import unittest
2
+
3
+ from opencraw import input_recipe, output_recipe, step
4
+
5
+
6
+ class BuildRecipeTest(unittest.TestCase):
7
+ def test_step_is_a_dict_with_its_type(self) -> None:
8
+ self.assertEqual(
9
+ step("goto", url="{{start.url}}"), {"type": "goto", "url": "{{start.url}}"}
10
+ )
11
+
12
+ def test_input_recipe_turns_a_string_start_into_a_url_point(self) -> None:
13
+ recipe = input_recipe(
14
+ "a",
15
+ "p",
16
+ "web",
17
+ ["https://a.example", {"url": "https://b.example"}],
18
+ [step("emit")],
19
+ )
20
+ self.assertEqual(
21
+ recipe["start"],
22
+ [{"url": "https://a.example"}, {"url": "https://b.example"}],
23
+ )
24
+ self.assertEqual(recipe["kind"], "input")
25
+ self.assertEqual(recipe["mapping"], {})
26
+
27
+ def test_extra_keys_pass_through_untouched(self) -> None:
28
+ recipe = input_recipe(
29
+ "a", "p", "web", ["u"], [], vars={"user": "demo"}, limits={"concurrency": 2}
30
+ )
31
+ self.assertEqual(recipe["vars"], {"user": "demo"})
32
+ self.assertEqual(recipe["limits"], {"concurrency": 2})
33
+
34
+ def test_output_recipe_defaults_to_version_one(self) -> None:
35
+ self.assertEqual(output_recipe("p", {"url": {"type": "url"}})["version"], 1)
36
+
37
+
38
+ if __name__ == "__main__":
39
+ unittest.main()
@@ -0,0 +1,92 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest import mock
7
+
8
+ from opencraw import (
9
+ OpenCrawError,
10
+ cli_command,
11
+ input_recipe,
12
+ output_recipe,
13
+ run,
14
+ step,
15
+ validate,
16
+ )
17
+
18
+ REPO_CLI = (
19
+ Path(__file__).resolve().parents[3] / "packages" / "cli" / "bin" / "opencraw.mjs"
20
+ )
21
+ CLI_BUILT = (REPO_CLI.parent.parent / "dist" / "index.esm.js").exists()
22
+
23
+
24
+ class CliCommandTest(unittest.TestCase):
25
+ def test_the_environment_wins(self) -> None:
26
+ with mock.patch.dict(os.environ, {"OPENCRAW_CLI": "node /x/cli.mjs"}):
27
+ self.assertEqual(cli_command(), ["node", "/x/cli.mjs"])
28
+
29
+
30
+ @unittest.skipUnless(CLI_BUILT, "the repository's CLI is not built")
31
+ class CliIntegrationTest(unittest.TestCase):
32
+ """Against the repository's own CLI, through the files this package writes."""
33
+
34
+ def setUp(self) -> None:
35
+ patcher = mock.patch.dict(os.environ, {"OPENCRAW_CLI": f'node "{REPO_CLI}"'})
36
+ patcher.start()
37
+ self.addCleanup(patcher.stop)
38
+ self.folder = tempfile.TemporaryDirectory()
39
+ self.addCleanup(self.folder.cleanup)
40
+ data = Path(self.folder.name) / "data.json"
41
+ data.write_text(
42
+ json.dumps({"items": [{"name": "Widget"}, {"name": "Gadget"}]}),
43
+ encoding="utf-8",
44
+ )
45
+ self.output = output_recipe(
46
+ "thing", {"name": {"type": "string", "required": True, "key": True}}
47
+ )
48
+ self.input = input_recipe(
49
+ "things",
50
+ "thing",
51
+ "api",
52
+ [data.as_uri()],
53
+ [
54
+ step("request", id="response", url="{{start.url}}", **{"as": "json"}),
55
+ step(
56
+ "extract",
57
+ id="items",
58
+ **{"from": "response"},
59
+ selector="$.items[*]",
60
+ kind="jsonpath",
61
+ take="json",
62
+ many=True,
63
+ ),
64
+ step("forEach", over="items", **{"as": "item"}, emit=True, steps=[]),
65
+ ],
66
+ {"name": {"from": "item.name"}},
67
+ )
68
+
69
+ def test_validate_accepts_good_recipes(self) -> None:
70
+ result = validate([self.output, self.input])
71
+ self.assertTrue(result.ok, result.output)
72
+
73
+ def test_validate_reports_a_bad_step_without_raising(self) -> None:
74
+ bad = {**self.input, "steps": [step("gotoo", url="x")]}
75
+ result = validate([self.output, bad])
76
+ self.assertFalse(result.ok)
77
+ self.assertIn("things", result.output)
78
+
79
+ def test_run_returns_the_records_as_dicts(self) -> None:
80
+ records = run([self.output, self.input])
81
+ self.assertEqual(
82
+ sorted(record["name"] for record in records), ["Gadget", "Widget"]
83
+ )
84
+
85
+ def test_run_raises_with_the_cli_message_on_failure(self) -> None:
86
+ bad = {**self.input, "steps": [step("gotoo", url="x")]}
87
+ with self.assertRaises(OpenCrawError):
88
+ run([self.output, bad])
89
+
90
+
91
+ if __name__ == "__main__":
92
+ unittest.main()
@@ -0,0 +1,112 @@
1
+ import io
2
+ import json
3
+ import subprocess
4
+ import sys
5
+ import unittest
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from opencraw import HookPending, HookRequest, serve_hook
10
+
11
+ CORE = (
12
+ Path(__file__).resolve().parents[3] / "packages" / "core" / "dist" / "index.esm.js"
13
+ )
14
+
15
+
16
+ def answer_to(request: dict[str, Any], handler: Any) -> dict[str, Any]:
17
+ out = io.StringIO()
18
+ serve_hook(handler, io.StringIO(json.dumps(request)), out)
19
+ answer: dict[str, Any] = json.loads(out.getvalue())
20
+ return answer
21
+
22
+
23
+ REQUEST = {
24
+ "kind": "hook",
25
+ "name": "slug",
26
+ "input": "Hello World",
27
+ "args": {"max": 5},
28
+ "context": {"recipeId": "books", "scope": {}},
29
+ "idempotencyKey": "abc",
30
+ }
31
+
32
+
33
+ class ServeHookTest(unittest.TestCase):
34
+ def test_the_return_value_is_the_output(self) -> None:
35
+ answer = answer_to(
36
+ REQUEST, lambda value, args, _request: f"{value}:{args['max']}"
37
+ )
38
+
39
+ self.assertEqual(answer, {"status": "ok", "output": "Hello World:5"})
40
+
41
+ def test_the_handler_sees_the_whole_request(self) -> None:
42
+ seen: list[HookRequest] = []
43
+
44
+ def handler(_value: Any, _args: dict[str, Any], request: HookRequest) -> None:
45
+ seen.append(request)
46
+
47
+ answer_to(REQUEST, handler)
48
+
49
+ self.assertEqual(seen[0]["context"]["recipeId"], "books")
50
+ self.assertEqual(seen[0]["idempotencyKey"], "abc")
51
+
52
+ def test_a_hook_step_has_no_input(self) -> None:
53
+ request = {key: value for key, value in REQUEST.items() if key != "input"}
54
+
55
+ answer = answer_to(request, lambda value, _args, _request: value is None)
56
+
57
+ self.assertEqual(answer["output"], True)
58
+
59
+ def test_an_exception_becomes_an_error_answer(self) -> None:
60
+ def handler(_value: Any, _args: dict[str, Any], _request: HookRequest) -> None:
61
+ raise ValueError("no such price")
62
+
63
+ self.assertEqual(
64
+ answer_to(REQUEST, handler), {"status": "error", "error": "no such price"}
65
+ )
66
+
67
+ def test_raising_hook_pending_answers_pending_with_the_wait(self) -> None:
68
+ def handler(_value: Any, _args: dict[str, Any], _request: HookRequest) -> None:
69
+ raise HookPending(250)
70
+
71
+ self.assertEqual(
72
+ answer_to(REQUEST, handler), {"status": "pending", "retryAfterMs": 250}
73
+ )
74
+
75
+ def test_hook_pending_without_a_wait_leaves_it_to_the_caller(self) -> None:
76
+ def handler(_value: Any, _args: dict[str, Any], _request: HookRequest) -> None:
77
+ raise HookPending()
78
+
79
+ self.assertEqual(answer_to(REQUEST, handler), {"status": "pending"})
80
+
81
+ def test_an_exception_without_a_message_names_its_type(self) -> None:
82
+ def handler(_value: Any, _args: dict[str, Any], _request: HookRequest) -> None:
83
+ raise KeyError
84
+
85
+ self.assertEqual(answer_to(REQUEST, handler)["error"], "KeyError")
86
+
87
+
88
+ @unittest.skipUnless(CORE.exists(), "the repository's core is not built")
89
+ class CommandHookIntegrationTest(unittest.TestCase):
90
+ """The real ``commandHook`` from the built core runs a script that uses ``serve_hook``."""
91
+
92
+ def test_a_python_script_is_a_hook(self) -> None:
93
+ package_root = Path(__file__).resolve().parents[1]
94
+ script = (
95
+ "from opencraw import serve_hook\n"
96
+ "serve_hook(lambda value, args, request: str(value).strip().upper() + '!' * args['bangs'])\n"
97
+ )
98
+ node = f"""
99
+ import {{ commandHook }} from {json.dumps(CORE.as_uri())}
100
+ const hook = commandHook('shout', [{json.dumps(sys.executable)}, '-c', {json.dumps(script)}], {{ cwd: {json.dumps(str(package_root))} }})
101
+ process.stdout.write(JSON.stringify(await hook(' hi ', {{ bangs: 2 }}, {{ recipeId: 'r', scope: {{}}, log: () => undefined }})))
102
+ """
103
+
104
+ result = subprocess.run(
105
+ ["node", "--input-type=module", "-e", node],
106
+ capture_output=True,
107
+ text=True,
108
+ check=False,
109
+ )
110
+
111
+ self.assertEqual(result.returncode, 0, result.stderr)
112
+ self.assertEqual(json.loads(result.stdout), "HI!!")
@@ -0,0 +1,33 @@
1
+ import json
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ from opencraw import input_recipe, output_recipe, write_recipes
7
+
8
+
9
+ class WriteRecipesTest(unittest.TestCase):
10
+ def test_writes_each_recipe_under_its_id_and_kind(self) -> None:
11
+ with tempfile.TemporaryDirectory() as folder:
12
+ target = Path(folder) / "recipes"
13
+ files = write_recipes(
14
+ target,
15
+ [output_recipe("p", {}), input_recipe("a", "p", "web", ["u"], [])],
16
+ )
17
+ self.assertEqual(
18
+ sorted(file.name for file in files), ["a.input.json", "p.output.json"]
19
+ )
20
+ self.assertEqual(
21
+ json.loads((target / "a.input.json").read_text(encoding="utf-8"))[
22
+ "output"
23
+ ],
24
+ "p",
25
+ )
26
+
27
+ def test_rejects_a_recipe_without_kind_or_id(self) -> None:
28
+ with tempfile.TemporaryDirectory() as folder, self.assertRaises(ValueError):
29
+ write_recipes(folder, [{"id": "x"}])
30
+
31
+
32
+ if __name__ == "__main__":
33
+ unittest.main()