Skip to content

Commit f171ce6

Browse files
committed
Swift: add unit tests to code generation
Tests can be run with ``` bazel test //swift/codegen:tests ``` Coverage can be checked installing `pytest-cov` and running ``` pytest --cov=swift/codegen swift/codegen/test ```
1 parent 2d05ea3 commit f171ce6

19 files changed

+1008
-149
lines changed

.github/workflows/swift-codegen.yml

+6-1
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@ jobs:
1919
cache: 'pip'
2020
- uses: ./.github/actions/fetch-codeql
2121
- uses: bazelbuild/setup-bazelisk@v2
22-
- name: Check code generation
22+
- name: Install dependencies
2323
run: |
2424
pip install -r swift/codegen/requirements.txt
25+
- name: Run unit tests
26+
run: |
27+
bazel test //swift/codegen:tests --test_output=errors
28+
- name: Check that code was generated
29+
run: |
2530
bazel run //swift/codegen
2631
git add swift
2732
git diff --exit-code --stat HEAD

.pre-commit-config.yaml

+7
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,10 @@ repos:
4040
language: system
4141
entry: bazel run //swift/codegen
4242
pass_filenames: false
43+
44+
- id: swift-codegen-unit-tests
45+
name: Run Swift code generation unit tests
46+
files: ^swift/codegen
47+
language: system
48+
entry: bazel test //swift/codegen:tests
49+
pass_filenames: false

conftest.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# this empty file adds the repo root to PYTHON_PATH when running pytest

swift/codegen/.coverage

52 KB
Binary file not shown.

swift/codegen/BUILD.bazel

+28-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,31 @@
11
py_binary(
22
name = "codegen",
3-
srcs = glob(["**/*.py"]),
3+
srcs = glob([
4+
"lib/*.py",
5+
"*.py",
6+
]),
7+
)
8+
9+
py_library(
10+
name = "test_utils",
11+
testonly = True,
12+
srcs = ["test/utils.py"],
13+
deps = [":codegen"],
14+
)
15+
16+
[
17+
py_test(
18+
name = src[len("test/"):-len(".py")],
19+
size = "small",
20+
srcs = [src],
21+
deps = [
22+
":codegen",
23+
":test_utils",
24+
],
25+
)
26+
for src in glob(["test/test_*.py"])
27+
]
28+
29+
test_suite(
30+
name = "tests",
431
)

swift/codegen/dbschemegen.py

+6-7
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
import inflection
55

6-
from lib import paths, schema, generator
7-
from lib.dbscheme import *
6+
from swift.codegen.lib import paths, schema, generator
7+
from swift.codegen.lib.dbscheme import *
88

99
log = logging.getLogger(__name__)
1010

@@ -60,7 +60,7 @@ def cls_to_dbscheme(cls: schema.Class):
6060

6161

6262
def get_declarations(data: schema.Schema):
63-
return [d for cls in data.classes.values() for d in cls_to_dbscheme(cls)]
63+
return [d for cls in data.classes for d in cls_to_dbscheme(cls)]
6464

6565

6666
def get_includes(data: schema.Schema, include_dir: pathlib.Path):
@@ -73,11 +73,10 @@ def get_includes(data: schema.Schema, include_dir: pathlib.Path):
7373

7474

7575
def generate(opts, renderer):
76-
input = opts.schema.resolve()
77-
out = opts.dbscheme.resolve()
76+
input = opts.schema
77+
out = opts.dbscheme
7878

79-
with open(input) as src:
80-
data = schema.load(src)
79+
data = schema.load(input)
8180

8281
dbscheme = DbScheme(src=input.relative_to(paths.swift_dir),
8382
includes=get_includes(data, include_dir=input.parent),

swift/codegen/lib/options.py

+8-4
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,17 @@
1010

1111
def _init_options():
1212
Option("--verbose", "-v", action="store_true")
13-
Option("--schema", tags=["schema"], type=pathlib.Path, default=paths.swift_dir / "codegen/schema.yml")
14-
Option("--dbscheme", tags=["dbscheme"], type=pathlib.Path, default=paths.swift_dir / "ql/lib/swift.dbscheme")
15-
Option("--ql-output", tags=["ql"], type=pathlib.Path, default=paths.swift_dir / "ql/lib/codeql/swift/generated")
16-
Option("--ql-stub-output", tags=["ql"], type=pathlib.Path, default=paths.swift_dir / "ql/lib/codeql/swift/elements")
13+
Option("--schema", tags=["schema"], type=_abspath, default=paths.swift_dir / "codegen/schema.yml")
14+
Option("--dbscheme", tags=["dbscheme"], type=_abspath, default=paths.swift_dir / "ql/lib/swift.dbscheme")
15+
Option("--ql-output", tags=["ql"], type=_abspath, default=paths.swift_dir / "ql/lib/codeql/swift/generated")
16+
Option("--ql-stub-output", tags=["ql"], type=_abspath, default=paths.swift_dir / "ql/lib/codeql/swift/elements")
1717
Option("--codeql-binary", tags=["ql"], default="codeql")
1818

1919

20+
def _abspath(x):
21+
return pathlib.Path(x).resolve()
22+
23+
2024
_options = collections.defaultdict(list)
2125

2226

swift/codegen/lib/paths.py

+7-4
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
import os
66

77
try:
8-
_workspace_dir = pathlib.Path(os.environ['BUILD_WORKSPACE_DIRECTORY']) # <- means we are using bazel run
8+
_workspace_dir = pathlib.Path(os.environ['BUILD_WORKSPACE_DIRECTORY']).resolve() # <- means we are using bazel run
99
swift_dir = _workspace_dir / 'swift'
10-
lib_dir = swift_dir / 'codegen' / 'lib'
1110
except KeyError:
1211
_this_file = pathlib.Path(__file__).resolve()
1312
swift_dir = _this_file.parents[2]
14-
lib_dir = _this_file.parent
1513

14+
lib_dir = swift_dir / 'codegen' / 'lib'
15+
templates_dir = lib_dir / 'templates'
1616

17-
exe_file = pathlib.Path(sys.argv[0]).resolve()
17+
try:
18+
exe_file = pathlib.Path(sys.argv[0]).resolve().relative_to(swift_dir)
19+
except ValueError:
20+
exe_file = pathlib.Path(sys.argv[0]).name

swift/codegen/lib/ql.py

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import pathlib
2+
from dataclasses import dataclass, field
3+
from typing import List, ClassVar
4+
5+
import inflection
6+
7+
8+
@dataclass
9+
class QlParam:
10+
param: str
11+
type: str = None
12+
first: bool = False
13+
14+
15+
@dataclass
16+
class QlProperty:
17+
singular: str
18+
type: str
19+
tablename: str
20+
tableparams: List[QlParam]
21+
plural: str = None
22+
params: List[QlParam] = field(default_factory=list)
23+
first: bool = False
24+
local_var: str = "x"
25+
26+
def __post_init__(self):
27+
if self.params:
28+
self.params[0].first = True
29+
while self.local_var in (p.param for p in self.params):
30+
self.local_var += "_"
31+
assert self.tableparams
32+
if self.type_is_class:
33+
self.tableparams = [x if x != "result" else self.local_var for x in self.tableparams]
34+
self.tableparams = [QlParam(x) for x in self.tableparams]
35+
self.tableparams[0].first = True
36+
37+
@property
38+
def indefinite_article(self):
39+
if self.plural:
40+
return "An" if self.singular[0] in "AEIO" else "A"
41+
42+
@property
43+
def type_is_class(self):
44+
return self.type[0].isupper()
45+
46+
47+
@dataclass
48+
class QlClass:
49+
template: ClassVar = 'ql_class'
50+
51+
name: str
52+
bases: List[str] = field(default_factory=list)
53+
final: bool = False
54+
properties: List[QlProperty] = field(default_factory=list)
55+
dir: pathlib.Path = pathlib.Path()
56+
imports: List[str] = field(default_factory=list)
57+
58+
def __post_init__(self):
59+
self.bases = sorted(self.bases)
60+
if self.properties:
61+
self.properties[0].first = True
62+
63+
@property
64+
def db_id(self):
65+
return "@" + inflection.underscore(self.name)
66+
67+
@property
68+
def root(self):
69+
return not self.bases
70+
71+
@property
72+
def path(self):
73+
return self.dir / self.name
74+
75+
76+
@dataclass
77+
class QlStub:
78+
template: ClassVar = 'ql_stub'
79+
80+
name: str
81+
base_import: str
82+
83+
84+
@dataclass
85+
class QlImportList:
86+
template: ClassVar = 'ql_imports'
87+
88+
imports: List[str] = field(default_factory=list)

swift/codegen/lib/render.py

+4-6
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ class Renderer:
1919
""" Template renderer using mustache templates in the `templates` directory """
2020

2121
def __init__(self):
22-
self.r = pystache.Renderer(search_dirs=str(paths.lib_dir / "templates"), escape=lambda u: u)
23-
self.generator = paths.exe_file.relative_to(paths.swift_dir)
22+
self._r = pystache.Renderer(search_dirs=str(paths.lib_dir / "templates"), escape=lambda u: u)
2423
self.written = set()
2524

2625
def render(self, data, output: pathlib.Path):
@@ -32,7 +31,7 @@ def render(self, data, output: pathlib.Path):
3231
"""
3332
mnemonic = type(data).__name__
3433
output.parent.mkdir(parents=True, exist_ok=True)
35-
data = self.r.render_name(data.template, data, generator=self.generator)
34+
data = self._r.render_name(data.template, data, generator=paths.exe_file)
3635
with open(output, "w") as out:
3736
out.write(data)
3837
log.debug(f"generated {mnemonic} {output.name}")
@@ -41,6 +40,5 @@ def render(self, data, output: pathlib.Path):
4140
def cleanup(self, existing):
4241
""" Remove files in `existing` for which no `render` has been called """
4342
for f in existing - self.written:
44-
if f.is_file():
45-
f.unlink()
46-
log.info(f"removed {f.name}")
43+
f.unlink(missing_ok=True)
44+
log.info(f"removed {f.name}")

swift/codegen/lib/schema.py

+13-13
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import pathlib
44
import re
55
from dataclasses import dataclass, field
6-
from enum import Enum, auto
76
from typing import List, Set, Dict, ClassVar
87

98
import yaml
@@ -47,7 +46,7 @@ class Class:
4746

4847
@dataclass
4948
class Schema:
50-
classes: Dict[str, Class]
49+
classes: List[Class]
5150
includes: Set[str] = field(default_factory=set)
5251

5352

@@ -65,6 +64,7 @@ def _parse_property(name, type):
6564

6665
class _DirSelector:
6766
""" Default output subdirectory selector for generated QL files, based on the `_directories` global field"""
67+
6868
def __init__(self, dir_to_patterns):
6969
self.selector = [(re.compile(p), pathlib.Path(d)) for d, p in dir_to_patterns]
7070
self.selector.append((re.compile(""), pathlib.Path()))
@@ -73,19 +73,19 @@ def get(self, name):
7373
return next(d for p, d in self.selector if p.search(name))
7474

7575

76-
def load(file):
77-
""" Parse the schema from `file` """
78-
data = yaml.load(file, Loader=yaml.SafeLoader)
76+
def load(path):
77+
""" Parse the schema from the file at `path` """
78+
with open(path) as input:
79+
data = yaml.load(input, Loader=yaml.SafeLoader)
7980
grouper = _DirSelector(data.get("_directories", {}).items())
80-
ret = Schema(classes={cls: Class(cls, dir=grouper.get(cls)) for cls in data if not cls.startswith("_")},
81-
includes=set(data.get("_includes", [])))
82-
assert root_class_name not in ret.classes
83-
ret.classes[root_class_name] = Class(root_class_name)
81+
classes = {root_class_name: Class(root_class_name)}
82+
assert root_class_name not in data
83+
classes.update((cls, Class(cls, dir=grouper.get(cls))) for cls in data if not cls.startswith("_"))
8484
for name, info in data.items():
8585
if name.startswith("_"):
8686
continue
8787
assert name[0].isupper()
88-
cls = ret.classes[name]
88+
cls = classes[name]
8989
for k, v in info.items():
9090
if not k.startswith("_"):
9191
cls.properties.append(_parse_property(k, v))
@@ -94,11 +94,11 @@ def load(file):
9494
v = [v]
9595
for base in v:
9696
cls.bases.add(base)
97-
ret.classes[base].derived.add(name)
97+
classes[base].derived.add(name)
9898
elif k == "_dir":
9999
cls.dir = pathlib.Path(v)
100100
if not cls.bases:
101101
cls.bases.add(root_class_name)
102-
ret.classes[root_class_name].derived.add(name)
102+
classes[root_class_name].derived.add(name)
103103

104-
return ret
104+
return Schema(classes=list(classes.values()), includes=set(data.get("_includes", [])))

0 commit comments

Comments
 (0)