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.
- opencraw-0.0.2/.gitignore +70 -0
- opencraw-0.0.2/PKG-INFO +98 -0
- opencraw-0.0.2/README.md +86 -0
- opencraw-0.0.2/opencraw/__init__.py +33 -0
- opencraw-0.0.2/opencraw/build_recipe_algorithm.py +52 -0
- opencraw-0.0.2/opencraw/hook_pending_error.py +12 -0
- opencraw-0.0.2/opencraw/opencraw_cli_client.py +82 -0
- opencraw-0.0.2/opencraw/opencraw_contract.py +36 -0
- opencraw-0.0.2/opencraw/opencraw_error.py +2 -0
- opencraw-0.0.2/opencraw/py.typed +0 -0
- opencraw-0.0.2/opencraw/serve_hook_use_case.py +40 -0
- opencraw-0.0.2/opencraw/write_recipes_use_case.py +24 -0
- opencraw-0.0.2/project.json +45 -0
- opencraw-0.0.2/pyproject.toml +34 -0
- opencraw-0.0.2/tests/test_build_recipe_algorithm.py +39 -0
- opencraw-0.0.2/tests/test_opencraw_cli_client.py +92 -0
- opencraw-0.0.2/tests/test_serve_hook_use_case.py +112 -0
- opencraw-0.0.2/tests/test_write_recipes_use_case.py +33 -0
|
@@ -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]
|
opencraw-0.0.2/PKG-INFO
ADDED
|
@@ -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).
|
opencraw-0.0.2/README.md
ADDED
|
@@ -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
|
|
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()
|