Skip to content

Commit a160c66

Browse files
authored
Ruleset inheritance dependencies (#65)
* Updated the example import.ys to have an inherited ruleset to test the inhertiance feature. Updated the grammar to include ruleset inheritance. Implemented detection by reusing the dependency manager and added a basic placeholder during the resolving process * Fixed pylint violation with the use of .keys() in the has_cycle method * Updated the resolve_ruleset_inheritance function to have an improved implementation that accounts for the structure of dependencies * Minor styling improvements to the resolve_ruleset_inheritance. Updated docstrings to account for the new exception. Added some additional test cases to confirm the inheritance management system is working * Added an additional test to the resolve_ruleset_inheritance to account for rulesets that do not have any inheritance requirements * Modified the import example to have a better example. Updated the test files to include additional cases such as a full parse of the inheritance schema * updated the setup.py version to 0.4.1 and updated the changelog * Added python version 3.11 to the test github workflow in the python version matrix * Updated the schema components documentation with an ruleset inheritance docs
1 parent a298e14 commit a160c66

File tree

15 files changed

+340
-36
lines changed

15 files changed

+340
-36
lines changed

.github/workflows/test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
runs-on: ubuntu-20.04
2929
strategy:
3030
matrix:
31-
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"]
31+
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"]
3232
max-parallel: 2
3333
fail-fast: true
3434
steps:

changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,9 @@
6060
* Added namespaces to the import statements with the `as` keyword
6161
* Improvements to the loading of rulesets to allow for schema files to be less restrictive in the order resources are defined.
6262
* Improvements to the grammar file to include new terminals and remove duplicate constructs
63+
64+
## v0.4.1 (29th April 2023)
65+
66+
* Added ruleset inheritance to the Yamlator schema syntax
67+
* Added dependency management to the ruleset inheritance process
68+
* Added Python 3.11 to the `test` Github Workflow

docs/schema_components.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ Below are the various components that can be used to construct a schema with Yam
1919
* [Union Type](#union-type)
2020
* [Importing schemas](#importing-schemas)
2121
* [Schema import paths](#schema-import-paths)
22+
* [Ruleset Inheritance](#ruleset-inheritance)
23+
* [Strict ruleset inheritance](#strict-ruleset-inheritance)
24+
* [Inheritance from imported schemas](#inheritance-from-imported-schemas)
2225

2326
## Schema
2427

@@ -476,3 +479,91 @@ main/
476479
The `apis` will be fetched from the `web` directory and `common.ys` is located in the same directory location as the `main.ys` file. An full example of importing schemas can be found in [example folder](../example/imports/).
477480

478481
__NOTE__: If an import cycle is detected, Yamlator will exit with a non-zero status code.
482+
483+
## Ruleset Inheritance
484+
485+
In Yamlator version `0.4.1`, rulesets can now inherit other rulesets to reduce duplicating rules. A ruleset can inherit other rulesets from the current schema or from schemas that have been imported.
486+
487+
__NOTE__: A ruleset can only inherit a single ruleset. Multi-inheritance is currently __NOT__ supported.
488+
489+
An example of ruleset inheritance where the `Employee` ruleset will inherit the rules from the `Person` ruleset:
490+
491+
```text
492+
ruleset Person {
493+
first_name str
494+
surname str
495+
}
496+
497+
ruleset Employee(Person) {
498+
employee_id str
499+
}
500+
```
501+
502+
The `Employee` ruleset will now contain a total of 3 rules, 2 from the `Person` ruleset and 1 rule from its own block.
503+
504+
If a child ruleset has a rule with the same name as the inherited ruleset, then the child rule will override the parent. For example, given the following example:
505+
506+
```text
507+
ruleset Versions {
508+
version str
509+
kind str
510+
}
511+
512+
ruleset Foo(Versions) {
513+
version int
514+
}
515+
```
516+
517+
In this case, the `Foo` ruleset will only have 2 rules. The `kind` rule will be inherited but the `version` rule in `Foo` will override the exiting rule in `Versions`.
518+
519+
### Strict Ruleset Inheritance
520+
521+
If the parent ruleset is a strict ruleset, strict mode will __NOT__ be inherited by the child ruleset. In order for the child ruleset to use strict mode, it must specify it is a strict ruleset in its definition. For example:
522+
523+
```text
524+
strict ruleset Foo {
525+
bar str
526+
baz int
527+
}
528+
529+
ruleset Request1(Foo) {
530+
id str
531+
}
532+
533+
strict ruleset Request2(Foo) {
534+
id str
535+
}
536+
```
537+
538+
In the above case, the ruleset `Request1` will not enforce strict mode. However `Request2` will enforce strict mode since it has been defined as a strict ruleset.
539+
540+
### Inheritance from imported schemas
541+
542+
Rulesets can be inherited by rulesets from other files that have been imported with or without a namespace. For example:
543+
544+
The given ruleset is located in a file called `common.ys`
545+
546+
```text
547+
ruleset Project {
548+
id str
549+
version str
550+
kind str
551+
}
552+
```
553+
554+
This ruleset can then be imported with or without a namespace and be inherited:
555+
556+
```text
557+
import Project from "common.ys" as common
558+
import Project from "common.ys"
559+
560+
ruleset AwesomeProject1(common.Project) {
561+
...
562+
}
563+
564+
ruleset AwesomeProject2(Project) {
565+
...
566+
}
567+
```
568+
569+
__NOTE__: If a inheritance cycle is detected, Yamlator will exit with a non-zero status code.

example/imports/common.ys

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import Project from "../strict_mode/strict.ys"
22

3-
enum Values {
3+
enum Envs {
44
TEST = 1
5+
DEV = 2
6+
PROD = 3
57
}
68

79
enum Status {

example/imports/import.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
project:
22
version: 1
3-
name: test
3+
name: my-awesome-project
4+
details:
5+
author: Foo Bar

example/imports/import.ys

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
import Values, Status, ProjectDetails from "common.ys" as core
1+
import Envs, Status, ProjectDetails from "common.ys" as core
22

33
schema {
44
project Project
5-
value core.Values
5+
env core.Envs
66
}
77

8-
strict ruleset Project {
8+
strict ruleset Project(Versions) {
99
status core.Status
1010
apis list(str)
1111
details core.ProjectDetails
12+
}
13+
14+
ruleset Versions {
15+
version str
16+
kind str
1217
}

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import setuptools
44

5-
VERSION = '0.4.0'
5+
VERSION = '0.4.1'
66
PACKAGE_NAME = 'yamlator'
77
DESCRIPTION = 'Yamlator is a CLI tool that allows a YAML file to be validated using a lightweight schema language' # nopep8
88

tests/cmd/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
VALID_SCHEMA = f'{_BASE_VALID_PATH}/valid.ys'
1818
VALID_KEYLESS_DIRECTIVE_SCHEMA = f'{_BASE_VALID_PATH}/keyless_directive.ys'
1919
VALID_KEYLESS_RULES_SCHEMA = f'{_BASE_VALID_PATH}/keyless_and_standard_rules.ys'
20+
VALID_INHERITANCE_SCHEMA = f'{_BASE_VALID_PATH}/inheritance.ys'
2021

2122
# These files don't exists and are used to force specific errors in the tests
2223
NONE_PATH = None

tests/files/valid/inheritance.ys

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,21 @@ import Status, User from "base.ys"
55
schema {
66
employees list(Employee)
77
status Status
8+
projects list(Project)
89
}
910

1011
ruleset Employee (base.Employee) {
1112
department str
1213
salary float
1314
}
15+
16+
ruleset Versions {
17+
version str
18+
kind str
19+
}
20+
21+
ruleset Project(Versions) {
22+
description str
23+
cost float
24+
timeline str
25+
}

tests/parser/loaders/test_resolve_ruleset_inheritance.py

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from yamlator.types import SchemaTypes
1111
from yamlator.types import YamlatorRuleset
1212
from yamlator.exceptions import ConstructNotFoundError
13+
from yamlator.exceptions import CycleDependencyError
1314
from yamlator.parser.loaders import resolve_ruleset_inheritance
1415

1516

@@ -27,7 +28,21 @@ class TestResolveRulesetInheritance(unittest.TestCase):
2728
is_strict=False,
2829
parent=RuleType(SchemaTypes.RULESET, lookup='Bar')
2930
)
30-
}, ConstructNotFoundError)
31+
}, ConstructNotFoundError),
32+
('with_cycle_ruleset', {
33+
'Foo': YamlatorRuleset(
34+
name='Foo',
35+
rules=[],
36+
is_strict=False,
37+
parent=RuleType(SchemaTypes.RULESET, lookup='Bar')
38+
),
39+
'Bar': YamlatorRuleset(
40+
name='Bar',
41+
rules=[],
42+
is_strict=False,
43+
parent=RuleType(SchemaTypes.RULESET, lookup='Foo')
44+
)
45+
}, CycleDependencyError)
3146
])
3247
def test_resolve_ruleset_raises_error(self, name: str, rulesets: Any,
3348
expected_exception: Exception):
@@ -37,7 +52,37 @@ def test_resolve_ruleset_raises_error(self, name: str, rulesets: Any,
3752
with self.assertRaises(expected_exception):
3853
resolve_ruleset_inheritance(rulesets)
3954

40-
def test_resolve_ruleset_inheritance(self):
55+
def test_resolve_ruleset_inheritance_without_ruleset_inheritance(self):
56+
rulesets = {
57+
'Foo': YamlatorRuleset(
58+
name='Foo',
59+
rules=[
60+
Rule('name', RuleType(SchemaTypes.STR), True),
61+
Rule('age', RuleType(SchemaTypes.INT), False)
62+
],
63+
is_strict=False,
64+
),
65+
'Bar': YamlatorRuleset(
66+
name='Bar',
67+
rules=[
68+
Rule('first_name', RuleType(SchemaTypes.STR), True),
69+
Rule('last_name', RuleType(SchemaTypes.STR), True)
70+
]
71+
)
72+
}
73+
74+
expected_foo_rule_count = 2
75+
expected_bar_rule_count = 2
76+
77+
updated_rules = resolve_ruleset_inheritance(rulesets)
78+
79+
actual_foo_rule_count = len(updated_rules['Foo'].rules)
80+
actual_bar_rule_count = len(updated_rules['Bar'].rules)
81+
82+
self.assertEqual(expected_foo_rule_count, actual_foo_rule_count)
83+
self.assertEqual(expected_bar_rule_count, actual_bar_rule_count)
84+
85+
def test_resolve_ruleset_inheritance_with_simple_inheritance(self):
4186
rulesets = {
4287
'Foo': YamlatorRuleset(
4388
name='Foo',
@@ -129,6 +174,96 @@ def test_resolve_ruleset_inheritance_without_any_parents(self):
129174
self.assertEqual(expected_ruleset_count, actual_ruleset_count)
130175
self.assertEqual(expected_foo_rule_count, actual_foo_rule_count)
131176

177+
def test_resolve_ruleset_inheritance_with_cascading_inheritance(self):
178+
rulesets = {
179+
'Foo': YamlatorRuleset(
180+
name='Foo',
181+
rules=[
182+
Rule('message', RuleType(SchemaTypes.STR), True),
183+
Rule('name', RuleType(SchemaTypes.STR), True),
184+
],
185+
is_strict=False,
186+
parent=RuleType(SchemaTypes.RULESET, lookup='Bar')
187+
),
188+
'Bar': YamlatorRuleset(
189+
name='Bar',
190+
rules=[
191+
Rule('number', RuleType(SchemaTypes.FLOAT), True),
192+
],
193+
parent=RuleType(SchemaTypes.RULESET, lookup='Faux')
194+
),
195+
'Faux': YamlatorRuleset(
196+
name='Faux',
197+
rules=[
198+
Rule('version', RuleType(SchemaTypes.STR), True),
199+
Rule('type', RuleType(SchemaTypes.STR), True),
200+
],
201+
is_strict=False
202+
)
203+
}
204+
205+
expected_foo_rule_count = 5
206+
expected_bar_rule_count = 3
207+
208+
updated_rules = resolve_ruleset_inheritance(rulesets)
209+
210+
actual_foo_rule_count = len(updated_rules['Foo'].rules)
211+
actual_bar_rule_count = len(updated_rules['Bar'].rules)
212+
213+
self.assertEqual(expected_foo_rule_count, actual_foo_rule_count)
214+
self.assertEqual(expected_bar_rule_count, actual_bar_rule_count)
215+
216+
def test_resolve_ruleset_inheritance_multiple_objects_same_dependency(self):
217+
rulesets = {
218+
'Foo': YamlatorRuleset(
219+
name='Foo',
220+
rules=[
221+
Rule('message', RuleType(SchemaTypes.STR), True),
222+
Rule('name', RuleType(SchemaTypes.STR), True),
223+
],
224+
is_strict=False,
225+
parent=RuleType(SchemaTypes.RULESET, lookup='Faux')
226+
),
227+
'Bar': YamlatorRuleset(
228+
name='Bar',
229+
rules=[
230+
Rule('number', RuleType(SchemaTypes.FLOAT), True),
231+
],
232+
parent=RuleType(SchemaTypes.RULESET, lookup='Faux')
233+
),
234+
'Baz': YamlatorRuleset(
235+
name='Baz',
236+
rules=[
237+
Rule('name', RuleType(SchemaTypes.STR), True),
238+
Rule('item', RuleType(SchemaTypes.STR), False),
239+
Rule('cost', RuleType(SchemaTypes.FLOAT), False),
240+
],
241+
parent=RuleType(SchemaTypes.RULESET, lookup='Bar')
242+
),
243+
'Faux': YamlatorRuleset(
244+
name='Faux',
245+
rules=[
246+
Rule('version', RuleType(SchemaTypes.STR), True),
247+
Rule('type', RuleType(SchemaTypes.STR), True),
248+
],
249+
is_strict=False
250+
)
251+
}
252+
253+
expected_foo_rule_count = 4
254+
expected_bar_rule_count = 3
255+
expected_baz_rule_count = 6
256+
257+
updated_rules = resolve_ruleset_inheritance(rulesets)
258+
259+
actual_foo_rule_count = len(updated_rules['Foo'].rules)
260+
actual_bar_rule_count = len(updated_rules['Bar'].rules)
261+
actual_baz_rule_count = len(updated_rules['Baz'].rules)
262+
263+
self.assertEqual(expected_foo_rule_count, actual_foo_rule_count)
264+
self.assertEqual(expected_bar_rule_count, actual_bar_rule_count)
265+
self.assertEqual(expected_baz_rule_count, actual_baz_rule_count)
266+
132267

133268
if __name__ == '__main__':
134269
unittest.main()

tests/parser/test_parse_schema.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ def test_parse_with_empty_text(self):
4646
constants.VALID_KEYLESS_DIRECTIVE_SCHEMA, 1),
4747
('with_keyless_schema_and_rules',
4848
constants.VALID_KEYLESS_RULES_SCHEMA, 2),
49+
('with_inheritance',
50+
constants.VALID_INHERITANCE_SCHEMA, 3),
4951
])
5052
def test_parse_with_valid_content(self, name: str, schema_path: str,
5153
expected_schema_rule_count: int):

yamlator/grammar/grammar.lark

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import_statement: "import" imported_types "from" IMPORT_STATEMENT_PATH ("as" NAM
1717
imported_types: (CONTAINER_TYPE_NAME ("," CONTAINER_TYPE_NAME)*)
1818

1919
schema_entry: STRICT_KEYWORD? "schema" "{" rule+ "}"
20-
ruleset: STRICT_KEYWORD? "ruleset" CONTAINER_TYPE_NAME "{" rule+ "}"
20+
ruleset: STRICT_KEYWORD? "ruleset" CONTAINER_TYPE_NAME (ruleset_parent)? "{" rule+ "}"
2121
ruleset_parent: "(" container_type ")"
2222

2323
// Enum constructs

yamlator/parser/core.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ def container_type(self, tokens: Iterator[Token]) -> RuleType:
210210
name = tokens[0]
211211
if len(tokens) > 1:
212212
name = f'{tokens[0]}.{tokens[1]}'
213+
else:
214+
name = name.value
213215

214216
schema_type = self.seen_constructs.get(name, SchemaTypes.UNKNOWN)
215217
rule_type = RuleType(schema_type=schema_type, lookup=name)

0 commit comments

Comments
 (0)