Skip to content

Commit f07f5ba

Browse files
authored
Make python models a bit more sane (#10)
1 parent a706a53 commit f07f5ba

34 files changed

+520
-492
lines changed

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Sometimes you want to modify the content or structure of an Atom or RSS feed, sa
66

77
## Concepts
88

9-
At the foundation, transformation rules are constructed of a `Condition` (which can be made up of many conditions) and a set of `Mutation`s to perform. Together a condition + mutations makes a `Rule`. A feed URL and a set of rules is a feed transformation. Elements of a feed to apply a condition or rule to are determined using [XPath queries](https://developer.mozilla.org/en-US/docs/Web/XPath), an XPath starting with a slack (`/`) indicates an absolute XPath, an XPath without a leading slash is assumed to be relative to the rule's XPath. XPaths are optional for conditions and mutations, if they are missing they are applied to the elements matched by the rule's XPath.
9+
At the foundation, transformation rules are constructed of a `Condition` (which can be made up of many conditions) and a set of `Mutation`s to perform. Together a condition + mutations makes a `Rule`. A feed URL and a set of rules is a `Feed Transform`. Elements of a feed to apply a condition or rule to are determined using [XPath queries](https://developer.mozilla.org/en-US/docs/Web/XPath), an XPath starting with a slash (`/`) indicates an absolute XPath, an XPath without a leading slash is assumed to be relative to the rule's XPath. XPaths are optional for conditions and mutations, if they are missing they are applied to the elements matched by the rule's XPath.
1010

1111
Currently the app supports the following conditions:
1212

@@ -15,7 +15,7 @@ contains:
1515
Tests whether or not the text of the element contains a substring
1616
1717
args:
18-
value: Substring to match
18+
pattern: Regular expression to match
1919
```
2020

2121
and the following mutations:
@@ -53,4 +53,3 @@ This uses `docker compose` to spin up the application locally hosted at `localho
5353

5454
- For frontend only work: `make dev-frontend` or `bun run dev`
5555
- For API work: `make dev-backend`
56-

app/feed_editor/rewrite/compression.py

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,18 @@
77
from feed_editor.rewrite.rules.mutations import MutationArgs
88
from feed_editor.rewrite.rules.types import (
99
ConditionDict,
10-
FeedRulesDict,
10+
FeedTransformDict,
1111
MutationDict,
12-
MutationDictWithoutXPath,
1312
RuleDict,
14-
SingleCondition,
15-
SingleConditionWithoutXPath,
13+
SingleConditionDict,
1614
)
1715
from feed_editor.utils.dict_validation import validate_dict
1816

1917
# Each key in the feed dict must have a unique mapping here
2018
# otherwise it wont be minified. If there is a collision then
2119
# decoding will return invalid results.
20+
# used:
21+
# abcdefgmnoqrstuwyx
2222
KEY_MINIFY_MAP = {
2323
"feed_url": "f",
2424
"rules": "q",
@@ -28,6 +28,10 @@
2828
"xpath": "x",
2929
"name": "n",
3030
"args": "b",
31+
# Feed Transform
32+
"version": "g",
33+
# Rules
34+
"rid": "e",
3135
# Conditions
3236
"contains": "c",
3337
"all_of": "a",
@@ -36,8 +40,6 @@
3640
"remove": "r",
3741
"replace": "s",
3842
"changeTag": "t",
39-
# contains
40-
"value": "v",
4143
# replace
4244
"pattern": "p",
4345
"replacement": "u",
@@ -49,15 +51,15 @@
4951
KEY_EXPAND_MAP = {v: k for k, v in KEY_MINIFY_MAP.items()}
5052

5153

52-
def compress_and_encode(rules: FeedRulesDict) -> str:
54+
def compress_and_encode(rules: FeedTransformDict) -> str:
5355
"""
5456
Takes a feed url and its transforms, minifies the dict, compresses it
5557
and urlsafe b64 encodes it
5658
"""
5759
return _gzip_encode(_simplify_feed_dict(rules))
5860

5961

60-
def decode_and_decompress(encoded: str) -> FeedRulesDict:
62+
def decode_and_decompress(encoded: str) -> FeedTransformDict:
6163
"""
6264
Takes a minified version of the feed rules dict and decodes it, decompresses it,
6365
and un-minifies it.
@@ -87,11 +89,11 @@ def _json_dumps(data: Mapping) -> str:
8789
return json.dumps(data, separators=(",", ":"), indent=None)
8890

8991

90-
def _simplify_feed_dict(rules: FeedRulesDict) -> dict:
92+
def _simplify_feed_dict(rules: FeedTransformDict) -> dict:
9193
def simplify_dict(args: Mapping) -> dict:
9294
return {KEY_MINIFY_MAP[k]: v for k, v in args.items()}
9395

94-
def simplify_typed_dict(typed: Union[MutationDict, SingleCondition]) -> dict:
96+
def simplify_typed_dict(typed: Union[MutationDict, SingleConditionDict]) -> dict:
9597
simple_dict = {
9698
KEY_MINIFY_MAP["name"]: typed["name"],
9799
KEY_MINIFY_MAP["args"]: simplify_dict(typed["args"]),
@@ -122,6 +124,8 @@ def simplify_mutation(mut: MutationDict) -> dict:
122124

123125
def simplify_rule(rule: RuleDict):
124126
return {
127+
KEY_MINIFY_MAP["rid"]: rule["rid"],
128+
KEY_MINIFY_MAP["name"]: rule["name"],
125129
KEY_MINIFY_MAP["xpath"]: rule["xpath"],
126130
KEY_MINIFY_MAP["condition"]: simplify_condition(rule["condition"]),
127131
KEY_MINIFY_MAP["mutations"]: [
@@ -130,12 +134,13 @@ def simplify_rule(rule: RuleDict):
130134
}
131135

132136
return {
137+
KEY_MINIFY_MAP["version"]: rules["version"],
133138
KEY_MINIFY_MAP["feed_url"]: rules["feed_url"],
134139
KEY_MINIFY_MAP["rules"]: [simplify_rule(rule) for rule in rules["rules"]],
135140
}
136141

137142

138-
def _feedify_simple_dict(simple_dict: dict) -> FeedRulesDict:
143+
def _feedify_simple_dict(simple_dict: dict) -> FeedTransformDict:
139144
def feedify_condition_args_dict(simple_args: dict) -> ConditionArgs:
140145
return cast(
141146
ConditionArgs, {KEY_EXPAND_MAP[k]: v for k, v in simple_args.items()}
@@ -161,31 +166,33 @@ def feedify_condition(simple_condition: dict) -> ConditionDict:
161166
for sc in simple_condition[KEY_MINIFY_MAP["any_of"]]
162167
]
163168
}
164-
cond_dict: SingleConditionWithoutXPath = {
169+
cond_dict: SingleConditionDict = {
165170
"name": simple_condition[KEY_MINIFY_MAP["name"]],
166171
"args": feedify_condition_args_dict(
167172
simple_condition[KEY_MINIFY_MAP["args"]]
168173
),
169174
}
170175

171176
if KEY_MINIFY_MAP["xpath"] in simple_condition:
172-
return {**cond_dict, "xpath": simple_condition[KEY_MINIFY_MAP["xpath"]]}
177+
cond_dict["xpath"] = simple_condition[KEY_MINIFY_MAP["xpath"]]
173178

174179
return cond_dict
175180

176181
def feedify_mutation(simple_mutation: dict) -> MutationDict:
177-
mut_dict: MutationDictWithoutXPath = {
182+
mut_dict: MutationDict = {
178183
"name": simple_mutation[KEY_MINIFY_MAP["name"]],
179184
"args": feedify_mutation_args_dict(simple_mutation[KEY_MINIFY_MAP["args"]]),
180185
}
181186

182187
if KEY_MINIFY_MAP["xpath"] in simple_mutation:
183-
return {**mut_dict, "xpath": simple_mutation[KEY_MINIFY_MAP["xpath"]]}
188+
mut_dict["xpath"] = simple_mutation[KEY_MINIFY_MAP["xpath"]]
184189

185190
return mut_dict
186191

187192
def feedify_rule(simple_rule: dict) -> RuleDict:
188193
return {
194+
"rid": simple_rule[KEY_MINIFY_MAP["rid"]],
195+
"name": simple_rule[KEY_MINIFY_MAP["name"]],
189196
"xpath": simple_rule[KEY_MINIFY_MAP["xpath"]],
190197
"condition": feedify_condition(simple_rule[KEY_MINIFY_MAP["condition"]]),
191198
"mutations": [
@@ -198,11 +205,12 @@ def feedify_rule(simple_rule: dict) -> RuleDict:
198205
KEY_MINIFY_MAP["feed_url"] not in simple_dict
199206
or KEY_MINIFY_MAP["rules"] not in simple_dict
200207
):
201-
return validate_dict(FeedRulesDict, simple_dict)
208+
return validate_dict(FeedTransformDict, simple_dict)
202209

203210
return validate_dict(
204-
FeedRulesDict,
211+
FeedTransformDict,
205212
{
213+
"version": simple_dict[KEY_MINIFY_MAP["version"]],
206214
"feed_url": simple_dict[KEY_MINIFY_MAP["feed_url"]],
207215
"rules": [
208216
feedify_rule(rule) for rule in simple_dict[KEY_MINIFY_MAP["rules"]]

app/feed_editor/rewrite/rewriter.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import validators # type: ignore
66

77
from feed_editor.rewrite.rules import run_rule
8-
from feed_editor.rewrite.rules.types import FeedRulesDict
8+
from feed_editor.rewrite.rules.types import FeedTransformDict
99
from feed_editor.rss.errors import FeedError
1010
from feed_editor.rss.fetch import Feed
1111
from feed_editor.rss.fetch import fetch_feed as rss_fetch
@@ -19,7 +19,7 @@ class FeedRewriter:
1919
to the rewritten feed
2020
"""
2121

22-
feed_rules: FeedRulesDict
22+
feed_transform: FeedTransformDict
2323

2424
@property
2525
def is_valid_feed(self) -> bool:
@@ -29,7 +29,7 @@ def is_valid_feed(self) -> bool:
2929
@property
3030
def feed_url(self) -> str:
3131
"""Get the URL of the feed being rewritten"""
32-
return self.feed_rules["feed_url"]
32+
return self.feed_transform["feed_url"]
3333

3434
@cached_property
3535
def feed(self) -> Feed:
@@ -41,7 +41,7 @@ def rewritten_feed(self) -> Feed:
4141
"""A rewritten version of the original feed after all rules are applied"""
4242
new_feed = self.feed.copy()
4343

44-
for rule_dict in self.feed_rules["rules"]:
44+
for rule_dict in self.feed_transform["rules"]:
4545
run_rule(new_feed.tree, rule_dict)
4646

4747
return new_feed

app/feed_editor/rewrite/routes.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
)
1111
from feed_editor.rewrite.rewriter import FeedRewriter
1212
from feed_editor.rewrite.rules import validate_dict, validate_xpaths
13-
from feed_editor.rewrite.rules.types import FeedRulesDict
13+
from feed_editor.rewrite.rules.types import FeedTransformDict
1414

1515
rewrite_api = Blueprint("rewrite", __name__, url_prefix="/rewrite")
1616

@@ -64,7 +64,7 @@ def url():
6464
return compress_and_encode(feed_dict)
6565

6666

67-
def _parse_args(params: Mapping[str, str]) -> FeedRulesDict:
67+
def _parse_args(params: Mapping[str, str]) -> FeedTransformDict:
6868
feed_data_gzipped: str | None = params.get("r", None)
6969

7070
if feed_data_gzipped:

app/feed_editor/rewrite/rules/__init__.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,24 @@
1313
from .types import (
1414
AndDict,
1515
ConditionDict,
16-
FeedRulesDict,
16+
FeedTransformDict,
1717
MutationDict,
1818
OrDict,
1919
RuleDict,
2020
)
2121

2222

23-
def validate_dict(test_dict: Mapping) -> FeedRulesDict:
23+
def validate_dict(test_dict: Mapping) -> FeedTransformDict:
2424
"""Validate that a dictionary represents a feed and rules to apply to it (FeedRulesDict)"""
25-
return generic_validate_dict(FeedRulesDict, test_dict)
25+
return generic_validate_dict(FeedTransformDict, test_dict)
2626

2727

28-
def validate_xpaths(feed_rules: FeedRulesDict) -> bool:
28+
def validate_xpaths(feed_transform: FeedTransformDict) -> bool:
2929
"""Traverses the entire dictionary and ensures that all rules and their
3030
mutations and conditions have valid xpaths, primarily that they are non-empty.
3131
3232
Args:
33-
feed_rules (FeedRulesDict): Feed URL and transformation rules
33+
feed_transform (FeedRulesDict): Feed URL and transformation rules
3434
3535
Returns:
3636
bool: True if all xpaths are valid, False otherwise
@@ -63,7 +63,7 @@ def validate_rule_xpaths(rule: RuleDict) -> bool:
6363
)
6464
)
6565

66-
return all(validate_rule_xpaths(rule) for rule in feed_rules["rules"])
66+
return all(validate_rule_xpaths(rule) for rule in feed_transform["rules"])
6767

6868

6969
def test_conditions_element(
@@ -95,8 +95,7 @@ def test_condition(condition: ConditionDict) -> bool:
9595
return test_disjunction(condition)
9696

9797
if condition["name"] in conditions_map:
98-
cond_dict = conditions_map[condition["name"]]
99-
cond = cond_dict["definition"]
98+
cond = conditions_map[condition["name"]]
10099

101100
if test_element is not None and test_element.text is not None:
102101
return cond(test_element.text, condition["args"])
@@ -137,7 +136,7 @@ def run_mutations_element(
137136
mut = mutation_map[mutation_dict["name"]]
138137
args = mutation_dict["args"]
139138

140-
mut["definition"](target_element, args)
139+
mut(target_element, args)
141140

142141

143142
def run_rule(tree: etree._ElementTree, rule: RuleDict) -> None:
Lines changed: 30 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,50 @@
11
# pylint: disable=too-few-public-methods,missing-class-docstring
2-
import functools
3-
from typing import TYPE_CHECKING, Callable, Protocol, TypedDict
4-
5-
from feed_editor.utils.dict_validation import _TypedDict_T, validate_dict
2+
import re
3+
from typing import (
4+
TYPE_CHECKING,
5+
Generic,
6+
Optional,
7+
Protocol,
8+
TypedDict,
9+
TypeVar,
10+
)
611

712
if TYPE_CHECKING:
813
from .types import ConditionDict
914

1015

11-
def _require_args(dict_type: type[_TypedDict_T]):
12-
def decorator(
13-
predicate: Callable[[str, _TypedDict_T], bool]
14-
) -> Callable[[str, "ConditionArgs"], bool]:
15-
@functools.wraps(predicate)
16-
def decorated(value, args: "ConditionArgs"):
17-
return predicate(value, validate_dict(dict_type, args))
18-
19-
return decorated
20-
21-
return decorator
22-
23-
2416
class ContainsArgs(TypedDict):
25-
"""Arguments to the contains condition"""
26-
27-
value: str
28-
29-
30-
@_require_args(ContainsArgs)
31-
def _contains(feed_value: str, args: ContainsArgs, /) -> bool:
32-
return args["value"] in feed_value
33-
34-
35-
def _contains_testval(xpath: str) -> "ConditionDict":
36-
return {"xpath": xpath, "name": "contains", "args": {"value": "test value"}}
17+
pattern: str
3718

3819

3920
ConditionArgs = ContainsArgs
21+
ConditionArgsT = TypeVar("ConditionArgsT", bound=ConditionArgs)
4022

4123

42-
class ConditionFn(Protocol): # pylint: disable=too-few-public-methods
43-
"""Required signature for a condition"""
44-
45-
@staticmethod
46-
def __call__(value: str, args: ConditionArgs, /) -> bool: ...
24+
class Condition(Protocol, Generic[ConditionArgsT]):
25+
ArgSpec: type[ConditionArgsT]
26+
name: str
4727

28+
def __call__(self, feed_value: str, args: ConditionArgsT) -> bool: ...
4829

49-
class TestFactory(Protocol):
50-
def __call__(self, xpath: str) -> "ConditionDict": ...
30+
def __test_factory__(
31+
self, xpath: str, args: Optional[ConditionArgsT] = None
32+
) -> "ConditionDict": ...
5133

5234

53-
class Condition(TypedDict):
54-
"""Base TypedDict for a Condition"""
35+
class Contains(Condition[ContainsArgs]):
36+
ArgSpec = ContainsArgs
37+
name = "contains"
5538

56-
display_name: str
57-
definition: ConditionFn
58-
arg_spec: type[ConditionArgs]
59-
test_factory: TestFactory
39+
def __call__(self, feed_value: str, args: ContainsArgs) -> bool:
40+
return re.search(args["pattern"], feed_value) is not None
6041

42+
def __test_factory__(
43+
self, xpath: str, args: ContainsArgs | None = None
44+
) -> "ConditionDict":
45+
args = args or {"pattern": ".+?"}
46+
return {"xpath": xpath, "name": self.name, "args": args}
6147

62-
all_conditions: list[Condition] = [
63-
{
64-
"display_name": "contains",
65-
"definition": _contains,
66-
"arg_spec": ContainsArgs,
67-
"test_factory": _contains_testval,
68-
}
69-
]
7048

71-
conditions_map: dict[str, Condition] = {
72-
cond["display_name"]: cond for cond in all_conditions
73-
}
49+
all_conditions: list[Condition] = [condcls() for condcls in Condition.__subclasses__()]
50+
conditions_map: dict[str, Condition] = {cond.name: cond for cond in all_conditions}

0 commit comments

Comments
 (0)