web2cli 0.2.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.
Files changed (44) hide show
  1. web2cli/__init__.py +3 -0
  2. web2cli/__main__.py +5 -0
  3. web2cli/adapter/__init__.py +0 -0
  4. web2cli/adapter/lint.py +667 -0
  5. web2cli/adapter/loader.py +157 -0
  6. web2cli/adapter/validator.py +127 -0
  7. web2cli/adapters/discord.com/web2cli.yaml +476 -0
  8. web2cli/adapters/mail.google.com/parsers/inbox.py +200 -0
  9. web2cli/adapters/mail.google.com/web2cli.yaml +52 -0
  10. web2cli/adapters/news.ycombinator.com/web2cli.yaml +356 -0
  11. web2cli/adapters/reddit.com/web2cli.yaml +233 -0
  12. web2cli/adapters/slack.com/web2cli.yaml +445 -0
  13. web2cli/adapters/stackoverflow.com/web2cli.yaml +257 -0
  14. web2cli/adapters/x.com/providers/x_graphql.py +299 -0
  15. web2cli/adapters/x.com/web2cli.yaml +449 -0
  16. web2cli/auth/__init__.py +0 -0
  17. web2cli/auth/browser_login.py +820 -0
  18. web2cli/auth/manager.py +166 -0
  19. web2cli/auth/store.py +68 -0
  20. web2cli/cli.py +1286 -0
  21. web2cli/executor/__init__.py +0 -0
  22. web2cli/executor/http.py +113 -0
  23. web2cli/output/__init__.py +0 -0
  24. web2cli/output/formatter.py +116 -0
  25. web2cli/parser/__init__.py +0 -0
  26. web2cli/parser/custom.py +21 -0
  27. web2cli/parser/html_parser.py +111 -0
  28. web2cli/parser/transforms.py +127 -0
  29. web2cli/pipe.py +10 -0
  30. web2cli/providers/__init__.py +6 -0
  31. web2cli/providers/base.py +22 -0
  32. web2cli/providers/registry.py +86 -0
  33. web2cli/runtime/__init__.py +1 -0
  34. web2cli/runtime/cache.py +42 -0
  35. web2cli/runtime/engine.py +743 -0
  36. web2cli/runtime/parser.py +398 -0
  37. web2cli/runtime/template.py +52 -0
  38. web2cli/types.py +71 -0
  39. web2cli-0.2.0.dist-info/METADATA +467 -0
  40. web2cli-0.2.0.dist-info/RECORD +44 -0
  41. web2cli-0.2.0.dist-info/WHEEL +5 -0
  42. web2cli-0.2.0.dist-info/entry_points.txt +2 -0
  43. web2cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  44. web2cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,157 @@
1
+ """Find and load adapter specs for a given domain or alias."""
2
+
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+
7
+ from web2cli.adapter.validator import validate_adapter
8
+ from web2cli.types import AdapterMeta, AdapterSpec, CommandArg, CommandSpec
9
+
10
+ # Built-in adapters ship inside the package (src/web2cli/adapters/)
11
+ _BUILTIN_ADAPTERS_DIR = Path(__file__).resolve().parent.parent / "adapters"
12
+
13
+ # User-installed adapters live in ~/.web2cli/adapters/
14
+ _USER_ADAPTERS_DIR = Path.home() / ".web2cli" / "adapters"
15
+
16
+
17
+ class AdapterNotFound(Exception):
18
+ pass
19
+
20
+
21
+ def _find_adapter_dir(domain_or_alias: str) -> tuple[Path, str]:
22
+ """Find the adapter directory for a domain or alias.
23
+
24
+ Returns (adapter_dir, resolved_domain).
25
+ Searches built-in first, then user-installed.
26
+ """
27
+ search_dirs = [_BUILTIN_ADAPTERS_DIR, _USER_ADAPTERS_DIR]
28
+
29
+ # First: try as a direct domain match
30
+ for base in search_dirs:
31
+ candidate = base / domain_or_alias
32
+ if (candidate / "web2cli.yaml").is_file():
33
+ return candidate, domain_or_alias
34
+
35
+ # Second: scan all adapters for alias match
36
+ for base in search_dirs:
37
+ if not base.is_dir():
38
+ continue
39
+ for adapter_dir in base.iterdir():
40
+ yaml_path = adapter_dir / "web2cli.yaml"
41
+ if not yaml_path.is_file():
42
+ continue
43
+ with open(yaml_path) as f:
44
+ spec = yaml.safe_load(f)
45
+ aliases = spec.get("meta", {}).get("aliases", [])
46
+ if domain_or_alias in aliases:
47
+ return adapter_dir, spec["meta"]["domain"]
48
+
49
+ raise AdapterNotFound(
50
+ f"No adapter found for '{domain_or_alias}'. "
51
+ f"Run 'web2cli adapters list' to see available adapters."
52
+ )
53
+
54
+
55
+ def _parse_command_arg(name: str, raw: dict) -> CommandArg:
56
+ """Parse a single command argument from YAML dict."""
57
+ arg_type = raw.get("type", "string")
58
+ # Be permissive for common synonym used in adapters/spec drafts.
59
+ if arg_type == "integer":
60
+ arg_type = "int"
61
+
62
+ return CommandArg(
63
+ name=name,
64
+ type=arg_type,
65
+ required=raw.get("required", False),
66
+ default=raw.get("default"),
67
+ description=raw.get("description", ""),
68
+ source=raw.get("source", ["arg"]),
69
+ enum=raw.get("enum"),
70
+ min=raw.get("min"),
71
+ max=raw.get("max"),
72
+ )
73
+
74
+
75
+ def _parse_command(name: str, raw: dict) -> CommandSpec:
76
+ """Parse a single command from YAML dict."""
77
+ if "request" in raw or "response" in raw:
78
+ raise ValueError(
79
+ f"Command '{name}': legacy request/response blocks are not supported in "
80
+ "spec v0.2; use pipeline steps"
81
+ )
82
+
83
+ raw_args = raw.get("args", {})
84
+ args = {k: _parse_command_arg(k, v) for k, v in raw_args.items()}
85
+
86
+ return CommandSpec(
87
+ name=name,
88
+ description=raw.get("description", ""),
89
+ args=args,
90
+ output=raw.get("output", {}),
91
+ pipeline=raw.get("pipeline", []),
92
+ )
93
+
94
+
95
+ def _parse_meta(raw: dict) -> AdapterMeta:
96
+ """Parse meta section from YAML dict."""
97
+ return AdapterMeta(
98
+ name=raw["name"],
99
+ domain=raw["domain"],
100
+ base_url=raw["base_url"],
101
+ version=raw.get("version", "0.0.0"),
102
+ description=raw.get("description", ""),
103
+ author=raw.get("author", ""),
104
+ spec_version=str(raw.get("spec_version", "0.2")),
105
+ transport=raw.get("transport", "http"),
106
+ impersonate=raw.get("impersonate"),
107
+ aliases=raw.get("aliases", []),
108
+ default_headers=raw.get("default_headers", {}),
109
+ )
110
+
111
+
112
+ def _parse_adapter(raw: dict) -> AdapterSpec:
113
+ """Parse full adapter spec from YAML dict."""
114
+ meta = _parse_meta(raw["meta"])
115
+ auth = raw.get("auth")
116
+ commands_raw = raw.get("commands", {})
117
+ commands = {k: _parse_command(k, v) for k, v in commands_raw.items()}
118
+
119
+ return AdapterSpec(
120
+ meta=meta,
121
+ auth=auth,
122
+ commands=commands,
123
+ resources=raw.get("resources", {}),
124
+ )
125
+
126
+
127
+ def load_adapter(domain_or_alias: str) -> AdapterSpec:
128
+ """Load adapter spec for a domain or alias.
129
+
130
+ Raises AdapterNotFound if no adapter matches.
131
+ """
132
+ adapter_dir, domain = _find_adapter_dir(domain_or_alias)
133
+ yaml_path = adapter_dir / "web2cli.yaml"
134
+
135
+ with open(yaml_path) as f:
136
+ raw = yaml.safe_load(f)
137
+
138
+ spec = _parse_adapter(raw)
139
+ spec.adapter_dir = adapter_dir
140
+ validate_adapter(spec, adapter_dir)
141
+ return spec
142
+
143
+
144
+ def list_adapters() -> list[AdapterSpec]:
145
+ """List all available adapters (built-in + user-installed)."""
146
+ adapters = []
147
+ for base in [_BUILTIN_ADAPTERS_DIR, _USER_ADAPTERS_DIR]:
148
+ if not base.is_dir():
149
+ continue
150
+ for adapter_dir in sorted(base.iterdir()):
151
+ yaml_path = adapter_dir / "web2cli.yaml"
152
+ if not yaml_path.is_file():
153
+ continue
154
+ with open(yaml_path) as f:
155
+ raw = yaml.safe_load(f)
156
+ adapters.append(_parse_adapter(raw))
157
+ return adapters
@@ -0,0 +1,127 @@
1
+ """Validate adapter specs."""
2
+
3
+ from pathlib import Path
4
+
5
+ from web2cli.types import AdapterSpec
6
+
7
+
8
+ class AdapterValidationError(Exception):
9
+ pass
10
+
11
+
12
+ def validate_adapter(spec: AdapterSpec, adapter_dir: Path) -> None:
13
+ """Validate an adapter spec.
14
+
15
+ Checks:
16
+ - Required meta fields present
17
+ - Referenced custom scripts exist on disk
18
+ """
19
+ # Required meta fields
20
+ for field in ("name", "domain", "base_url"):
21
+ if not getattr(spec.meta, field, None):
22
+ raise AdapterValidationError(
23
+ f"Adapter missing required meta field: {field}"
24
+ )
25
+
26
+ if not spec.meta.spec_version.startswith("0.2"):
27
+ raise AdapterValidationError(
28
+ "Only adapter spec_version 0.2 is supported"
29
+ )
30
+
31
+ # Validate commands
32
+ for cmd_name, cmd in spec.commands.items():
33
+ # Validate command args
34
+ stdin_count = 0
35
+ for arg_name, arg in cmd.args.items():
36
+ if arg.type not in {"string", "int", "float", "bool", "flag", "string[]"}:
37
+ raise AdapterValidationError(
38
+ f"Command '{cmd_name}': arg '{arg_name}' has unsupported type "
39
+ f"'{arg.type}'. Supported: string, int, float, bool, flag, string[]"
40
+ )
41
+
42
+ if not isinstance(arg.source, list) or not arg.source:
43
+ raise AdapterValidationError(
44
+ f"Command '{cmd_name}': arg '{arg_name}' must define "
45
+ f"'source' as a non-empty list"
46
+ )
47
+
48
+ invalid_sources = [s for s in arg.source if s not in {"arg", "stdin"}]
49
+ if invalid_sources:
50
+ raise AdapterValidationError(
51
+ f"Command '{cmd_name}': arg '{arg_name}' has invalid source(s) "
52
+ f"{invalid_sources}. Supported: arg, stdin"
53
+ )
54
+
55
+ if "stdin" in arg.source:
56
+ stdin_count += 1
57
+
58
+ if stdin_count > 1:
59
+ raise AdapterValidationError(
60
+ f"Command '{cmd_name}': only one argument can use 'stdin' source"
61
+ )
62
+
63
+ if not cmd.pipeline:
64
+ raise AdapterValidationError(
65
+ f"Command '{cmd_name}': pipeline is required for spec v0.2"
66
+ )
67
+
68
+ if not isinstance(cmd.pipeline, list):
69
+ raise AdapterValidationError(
70
+ f"Command '{cmd_name}': pipeline must be a list"
71
+ )
72
+
73
+ for idx, raw_step in enumerate(cmd.pipeline):
74
+ if not isinstance(raw_step, dict):
75
+ raise AdapterValidationError(
76
+ f"Command '{cmd_name}': pipeline step {idx} must be an object"
77
+ )
78
+
79
+ step_keys = [
80
+ k for k in raw_step.keys()
81
+ if k in {"request", "resolve", "fanout", "parse", "transform"}
82
+ ]
83
+ if len(step_keys) != 1:
84
+ raise AdapterValidationError(
85
+ f"Command '{cmd_name}': pipeline step {idx} must contain exactly "
86
+ f"one step type (request|resolve|fanout|parse|transform)"
87
+ )
88
+ step_type = step_keys[0]
89
+ step_spec = raw_step.get(step_type) or {}
90
+
91
+ if not isinstance(step_spec, dict):
92
+ raise AdapterValidationError(
93
+ f"Command '{cmd_name}': {step_type} step {idx} must be an object"
94
+ )
95
+
96
+ if step_type == "resolve":
97
+ resource_name = step_spec.get("resource")
98
+ if not resource_name:
99
+ raise AdapterValidationError(
100
+ f"Command '{cmd_name}': resolve step {idx} missing resource"
101
+ )
102
+ if resource_name not in spec.resources:
103
+ raise AdapterValidationError(
104
+ f"Command '{cmd_name}': resolve step {idx} references unknown "
105
+ f"resource '{resource_name}'"
106
+ )
107
+
108
+ if step_type == "fanout":
109
+ if "request" not in step_spec:
110
+ raise AdapterValidationError(
111
+ f"Command '{cmd_name}': fanout step {idx} missing request block"
112
+ )
113
+ if not isinstance(step_spec.get("request"), dict):
114
+ raise AdapterValidationError(
115
+ f"Command '{cmd_name}': fanout step {idx} request must be an object"
116
+ )
117
+
118
+ if step_type == "parse" and step_spec.get("parser") == "custom":
119
+ script = step_spec.get("script")
120
+ if not script:
121
+ raise AdapterValidationError(
122
+ f"Command '{cmd_name}': parse step {idx} custom parser missing script"
123
+ )
124
+ if not (adapter_dir / script).is_file():
125
+ raise AdapterValidationError(
126
+ f"Command '{cmd_name}': parse step {idx} parser script not found: {script}"
127
+ )