Skip to content

Commit cd5c247

Browse files
committed
Extensibility Refactor
Considerable refactor to allow easier extension and addition of new analyzers for other exercises. Adds a lib/common area and extends the bin/analyze.py, lib/two-fer, and tests to utilize this common functionality. Updates path handling and imports, and refactors significant constants into Enum subclasses for easy identification.
1 parent d9f4ea8 commit cd5c247

File tree

12 files changed

+634
-263
lines changed

12 files changed

+634
-263
lines changed

.gitignore

+5-1
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,8 @@ fabric.properties
6565
.idea/httpRequests
6666

6767
# Android studio 3.1+ serialized cache file
68-
.idea/caches/build_file_checksums.ser
68+
.idea/caches/build_file_checksums.ser
69+
70+
# Pytest cache files
71+
.pytest_cache
72+
__pycache__

README.md

+6
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,9 @@ For example:
99
```bash
1010
./bin/analyze.sh two-fer ~/solution-238382y7sds7fsadfasj23j/
1111
```
12+
13+
Unit tests can be run from this directory:
14+
15+
```bash
16+
pylint -x
17+
```

bin/analyze.py

+9-40
Original file line numberDiff line numberDiff line change
@@ -8,53 +8,21 @@
88
from pathlib import Path
99
from typing import NamedTuple
1010

11-
ROOT = Path(__file__).resolve(strict=True)
12-
LIBRARY = ROOT.parent.parent.joinpath("lib").resolve(strict=True)
13-
ANALYZERS = {f.parent.name: f for f in LIBRARY.glob("*/analyzer.py")}
11+
ROOT = Path(__file__).resolve(strict=True).parent
12+
LIBRARY = ROOT.parent.joinpath("lib").resolve(strict=True)
1413

15-
class Exercise(NamedTuple):
16-
"""
17-
An individual Exercise to anaylze.
18-
"""
19-
20-
name: str
21-
path: Path
22-
tests_path: Path
23-
24-
def analyze(self):
25-
"""
26-
Perform automatic analysis on this Exercise.
27-
"""
28-
module_name = f"{self.path.name}_analyzer"
29-
module = ANALYZERS[self.name]
30-
spec = importlib.util.spec_from_file_location(module_name, module)
31-
analyzer = importlib.util.module_from_spec(spec)
32-
spec.loader.exec_module(analyzer)
33-
sys.modules[module_name] = analyzer
34-
return analyzer.analyze(str(self.path))
14+
# add the library to sys.path so common modules can be imported by analyzer
15+
if str(LIBRARY) not in sys.path:
16+
sys.path.insert(0, str(LIBRARY))
3517

36-
@staticmethod
37-
def sanitize_name(name: str) -> str:
38-
"""
39-
Sanitize an Exercise name (ie "two-fer" -> "two_fer").
40-
"""
41-
return name.replace("-", "_")
42-
43-
@classmethod
44-
def factory(cls, name: str, directory: Path) -> "Exercise":
45-
"""
46-
Build an Exercise from its name and the directory where its files exist.
47-
"""
48-
sanitized = cls.sanitize_name(name)
49-
path = directory.joinpath(f"{sanitized}.py").resolve()
50-
tests_path = directory.joinpath(f"{sanitized}_test.py").resolve()
51-
return cls(name, path, tests_path)
18+
from common import Exercise, ExerciseError
5219

5320

5421
def main():
5522
"""
5623
Parse CLI arguments and perform the analysis.
5724
"""
25+
5826
def directory(path: str) -> Path:
5927
selection = Path(path)
6028
if not selection.is_dir():
@@ -68,7 +36,7 @@ def directory(path: str) -> Path:
6836
"exercise",
6937
metavar="EXERCISE",
7038
type=str,
71-
choices=ANALYZERS.keys(),
39+
choices=sorted(Exercise.available_analyzers().keys()),
7240
help="name of the exercise to analyze (One of: %(choices)s)",
7341
)
7442
parser.add_argument(
@@ -78,6 +46,7 @@ def directory(path: str) -> Path:
7846
help="directory where the the [EXERCISE].py file located",
7947
)
8048
args = parser.parse_args()
49+
8150
exercise = Exercise.factory(args.exercise, args.directory)
8251
exercise.analyze()
8352

lib/common/__init__.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""
2+
Common utilities for analyis of Exercism exercises.
3+
"""
4+
from .exercise import Exercise, ExerciseError
5+
from .comment import BaseComments
6+
from .analysis import Analysis, Status
7+
from .testing import BaseExerciseTest

lib/common/analysis.py

+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""
2+
Utility classes for analysis persistence.
3+
"""
4+
5+
import json
6+
from pathlib import Path
7+
from enum import Enum, auto, unique
8+
from typing import List
9+
10+
Comments = List[Enum]
11+
PylintComments = List[str]
12+
13+
14+
@unique
15+
class Status(Enum):
16+
"""
17+
Status of the exercise under analysis.
18+
"""
19+
20+
APPROVE_AS_OPTIMAL = auto()
21+
APPROVE_WITH_COMMENT = auto()
22+
DISAPPROVE_WITH_COMMENT = auto()
23+
REFER_TO_MENTOR = auto()
24+
25+
def __str__(self):
26+
return self.name.lower()
27+
28+
def __repr__(self):
29+
return f"{self.__class__.__name__}.{self.name}"
30+
31+
32+
class AnalysisEncoder(json.JSONEncoder):
33+
"""
34+
Simple encoder that will punt an Enum out as its string.
35+
"""
36+
37+
def default(self, obj):
38+
if isinstance(obj, Enum):
39+
return str(obj)
40+
return json.JSONEncoder.default(self, obj)
41+
42+
43+
class Analysis(dict):
44+
"""
45+
Represents the current state of the analysis of an exercise.
46+
"""
47+
48+
def __init__(self, status, comment, pylint_comment, approve=False):
49+
super(Analysis, self).__init__(
50+
status=status, comment=comment, pylint_comment=pylint_comment
51+
)
52+
self._approved = approve
53+
54+
@property
55+
def status(self) -> Status:
56+
"""
57+
The current status of the analysis.
58+
"""
59+
return self["status"]
60+
61+
@property
62+
def comment(self) -> Comments:
63+
"""
64+
The list of comments for the analysis.
65+
"""
66+
return self["comment"]
67+
68+
@property
69+
def pylint_comment(self) -> PylintComments:
70+
"""
71+
The list of pylint comments for the analysis.
72+
"""
73+
return self["pylint_comment"]
74+
75+
@property
76+
def approved(self):
77+
"""
78+
Is this analysis _considered_ approve-able?
79+
Note that this does not imply an approved status, but that the exercise
80+
has hit sufficient points that a live Mentor would likely approve it.
81+
"""
82+
return self._approved
83+
84+
@classmethod
85+
def approve_as_optimal(cls, comment=None, pylint_comment=None):
86+
"""
87+
Create an Anaylsis that is approved as optimal.
88+
"""
89+
return cls(
90+
Status.APPROVE_AS_OPTIMAL, comment or [], pylint_comment or [], approve=True
91+
)
92+
93+
@classmethod
94+
def approve_with_comment(cls, comment, pylint_comment=None):
95+
"""
96+
Create an Analysis that is approved with comment.
97+
"""
98+
return cls(
99+
Status.APPROVE_WITH_COMMENT, comment, pylint_comment or [], approve=True
100+
)
101+
102+
@classmethod
103+
def disapprove_with_comment(cls, comment, pylint_comment=None):
104+
"""
105+
Create an Analysis that is disapproved with comment.
106+
"""
107+
return cls(Status.DISAPPROVE_WITH_COMMENT, comment, pylint_comment or [])
108+
109+
@classmethod
110+
def refer_to_mentor(cls, comment, pylint_comment=None, approve=False):
111+
"""
112+
Create an Analysis that should be referred to a mentor.
113+
"""
114+
return cls(
115+
Status.REFER_TO_MENTOR, comment, pylint_comment or [], approve=approve
116+
)
117+
118+
def dump(self, path: Path):
119+
"""
120+
Dump's the current state to analysis.json.
121+
As a convenience returns the Anaylsis itself.
122+
"""
123+
with open(path, "w") as dst:
124+
json.dump(self, dst, indent=4, cls=AnalysisEncoder)
125+
return self

lib/common/comment.py

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""
2+
Classes for working with comments.
3+
"""
4+
from enum import Enum, unique
5+
6+
7+
@unique
8+
class BaseComments(Enum):
9+
"""
10+
Superclass for all analyzers to user to build their Comments.
11+
"""
12+
13+
def __new__(cls, namespace, comment):
14+
obj = object.__new__(cls)
15+
obj._value_ = f"python.{namespace}.{comment}".lower()
16+
return obj
17+
18+
def __str__(self):
19+
return self.value
20+
21+
def __repr__(self):
22+
return f"{self.__class__.__name__}.{self.name}"

lib/common/exercise.py

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""
2+
Helpers for exercise discovery and execution.
3+
"""
4+
import sys
5+
import importlib
6+
from pathlib import Path
7+
from typing import NamedTuple
8+
9+
ROOT = Path(__file__).resolve(strict=True).parent
10+
LIBRARY = ROOT.parent.resolve(strict=True)
11+
12+
# map each available exercise name to its EXERCISE/anaylzer.py module
13+
ANALYZERS = {f.parent.name: f for f in LIBRARY.glob("*/analyzer.py")}
14+
15+
16+
class ExerciseError(Exception):
17+
"""
18+
Exception to raise if there's a problem building an Exercise
19+
"""
20+
21+
22+
class Exercise(NamedTuple):
23+
"""
24+
Manages analysis of a an individual Exercise.
25+
"""
26+
27+
name: str
28+
path: Path
29+
tests_path: Path
30+
31+
@property
32+
def analyzer(self):
33+
"""
34+
The analyzer.py module for this Exercise, imported lazily.
35+
"""
36+
module_name = f"{Exercise.sanitize_name(self.name)}_analyzer"
37+
if module_name not in sys.modules:
38+
module = self.available_analyzers()[self.name]
39+
spec = importlib.util.spec_from_file_location(module_name, module)
40+
analyzer = importlib.util.module_from_spec(spec)
41+
spec.loader.exec_module(analyzer)
42+
sys.modules[module_name] = analyzer
43+
return sys.modules[module_name]
44+
45+
@property
46+
def comments(self):
47+
"""
48+
The comments defined in the analyzer.py module.
49+
"""
50+
return self.analyzer.Comments
51+
52+
def analyze(self):
53+
"""
54+
Perform automatic analysis on this Exercise.
55+
"""
56+
return self.analyzer.analyze(self.path)
57+
58+
@staticmethod
59+
def sanitize_name(name: str) -> str:
60+
"""
61+
Sanitize an Exercise name (ie "two-fer" -> "two_fer").
62+
"""
63+
return name.replace("-", "_")
64+
65+
@classmethod
66+
def available_analyzers(cls):
67+
"""
68+
Returns the map of avaiable EXERCISE/analyzer.py files.
69+
"""
70+
return ANALYZERS
71+
72+
@classmethod
73+
def factory(cls, name: str, directory: Path) -> "Exercise":
74+
"""
75+
Build an Exercise from its name and the directory where its files exist.
76+
"""
77+
if name not in cls.available_analyzers():
78+
path = LIBRARY.joinpath(name, "analyzer.py")
79+
raise ExerciseError(f"No analyzer discovered at {path}")
80+
sanitized = cls.sanitize_name(name)
81+
path = directory.joinpath(f"{sanitized}.py").resolve()
82+
tests_path = directory.joinpath(f"{sanitized}_test.py").resolve()
83+
return cls(name, path, tests_path)

0 commit comments

Comments
 (0)