Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue 20: Exercise Names + A Refactor to Bring Into Spec Compliance #22

Merged
merged 28 commits into from
Apr 16, 2021
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e3f1cb1
Changed imported names to match refactor.
BethanyG Apr 13, 2021
f725721
Added functionality to formulate paths and slugs from config.json.
BethanyG Apr 13, 2021
f13314f
Functions to create and process pylint comments and reports.
BethanyG Apr 13, 2021
be39341
Moved the Status class to common/comment.py and renamed it to Summary…
BethanyG Apr 13, 2021
650ec1f
Added dataclass Comment and Enum CommentTypes. Renamed and refactore…
BethanyG Apr 13, 2021
c6b2821
Removed checks for approval status from tests. Renamed references to…
BethanyG Apr 13, 2021
6db5c8a
Refactored and removed pylint code to its own file. General renaming…
BethanyG Apr 13, 2021
ce07338
Removed extra directory level from comment pointer string for pylint.
BethanyG Apr 13, 2021
c44e609
Added an init to make pytest happier.
BethanyG Apr 14, 2021
ebb7503
Added classmethod to process analyzer comments and add Analysis summa…
BethanyG Apr 14, 2021
749abd0
Refactored tests to match renamed and re-arranged code.
BethanyG Apr 14, 2021
accb6b8
Moved comment summarization code out of module to Analysis.py, and ma…
BethanyG Apr 14, 2021
2b60c38
General test refactoring and work to make tests pass with new code re…
BethanyG Apr 14, 2021
085606c
Added CI workflow.
BethanyG Apr 14, 2021
0b4e3cb
Added dec-requirements.txt to buld for CI.
BethanyG Apr 14, 2021
259de53
Added pytest to a dev-requirements file.
BethanyG Apr 14, 2021
26aef3d
Added and on PR condition.
BethanyG Apr 14, 2021
98ac9df
Master to main.
BethanyG Apr 14, 2021
23aa85c
Removed the erronous .py from dev-requirements.txt.
BethanyG Apr 14, 2021
cecdb4c
Update dev-requirements.txt.py
BethanyG Apr 14, 2021
ffbefa8
Update lib/common/__init__.py
BethanyG Apr 14, 2021
0e18ff1
Update lib/common/comment.py
BethanyG Apr 14, 2021
99c32eb
Update lib/common/pylint_comments.py
BethanyG Apr 14, 2021
07be9de
Update lib/common/analysis.py
BethanyG Apr 15, 2021
6a61423
Update lib/common/comment.py
BethanyG Apr 15, 2021
32793ef
Update lib/common/comment.py
BethanyG Apr 15, 2021
82d5033
Update lib/common/comment.py
BethanyG Apr 15, 2021
5d713bf
Delete dev-requirements.txt.py
BethanyG Apr 16, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
name: CI

on:
pull_request:
branches:
- main
paths-ignore:
- '.gitignore'
- 'LICENSE'
- '**.md'
push:
paths-ignore:
- '.gitignore'
- 'LICENSE'
- '**.md'

jobs:
test:
name: Test Analyzer
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1

- name: Build Docker Image
run: docker build -f Dockerfile -t python-analyzer .

- name: Run Tests
run: docker run --entrypoint "pytest" python-analyzer
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ RUN mkdir /opt/analyzer
COPY . /opt/analyzer
WORKDIR /opt/analyzer

RUN pip install -r requirements.txt
RUN pip install -r requirements.txt -r dev-requirements.txt
1 change: 1 addition & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pytest>=5.3.1,!=5.4.0
1 change: 1 addition & 0 deletions dev-requirements.txt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pytest>=5.3.1,!=5.4.0
BethanyG marked this conversation as resolved.
Show resolved Hide resolved
6 changes: 3 additions & 3 deletions lib/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
Common utilities for analyis of Exercism exercises.
"""
from .exercise import Exercise, ExerciseError
from .comment import BaseComments
from .analysis import Analysis, Status
from .testing import BaseExerciseTest
from .comment import BaseFeedback, Summary
from .analysis import Analysis
from .testing import BaseExerciseTest
101 changes: 44 additions & 57 deletions lib/common/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,9 @@

import json
from pathlib import Path
from enum import Enum, auto, unique
from typing import List

Comments = List[Enum]
PylintComments = List[str]


@unique
class Status(Enum):
"""
Status of the exercise under analysis.
"""

APPROVE = auto()
DISAPPROVE = auto()
REFER_TO_MENTOR = auto()

def __str__(self):
return self.name.lower()

def __repr__(self):
return f"{self.__class__.__name__}.{self.name}"
from enum import Enum
from dataclasses import asdict, is_dataclass
from common.comment import Comment, CommentTypes, Summary


class AnalysisEncoder(json.JSONEncoder):
Expand All @@ -36,6 +17,10 @@ class AnalysisEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Enum):
return str(obj)

elif is_dataclass(obj):
return asdict(obj)

return json.JSONEncoder.default(self, obj)


Expand All @@ -44,67 +29,69 @@ class Analysis(dict):
Represents the current state of the analysis of an exercise.
"""

def __init__(self, status, comment, pylint_comment, approvable=False):
super(Analysis, self).__init__(
status=status, comment=comment, pylint_comment=pylint_comment
)
self._approvable = approvable
def __init__(self, summary, comments):
super(Analysis, self).__init__(summary=summary, comments=comments)

@property
def status(self) -> Status:
"""
The current status of the analysis.
"""
return self["status"]

@property
def comment(self) -> Comments:
def summary(self) -> Summary:
"""
The list of comments for the analysis.
The current summary of the analysis.
"""
return self["comment"]

@property
def pylint_comment(self) -> PylintComments:
"""
The list of pylint comments for the analysis.
"""
return self["pylint_comment"]
return self["summary"]

@property
def approvable(self):
def comment(self):
"""
Is this analysis _considered_ approvable?
Note that this does not imply an approved status, but that the exercise
has hit sufficient points that a live Mentor would likely approve it.
The list of comments for the analysis.
"""
return self._approvable
return self["comments"]

@classmethod
def approve(cls, comment=None, pylint_comment=None):
def celebrate(cls, comments=None):
"""
Create an Anaylsis that is approved.
If non-optimal, comment should be a list of Comments.
"""
return cls(
Status.APPROVE, comment or [], pylint_comment or [], approvable=True
)
return cls(Summary.CELEBRATE, comments or [])


@classmethod
def disapprove(cls, comment, pylint_comment=None):
def require(cls, comments):
"""
Create an Analysis that is disapproved.
"""
return cls(Status.DISAPPROVE, comment, pylint_comment or [])
return cls(Summary.REQUIRE, comments)

@classmethod
def refer_to_mentor(cls, comment, pylint_comment=None, approvable=False):
def direct(cls, comments):
"""
Create an Analysis that should be referred to a mentor.
"""
return cls(
Status.REFER_TO_MENTOR, comment, pylint_comment or [], approvable=approvable
)
return cls(Summary.DIRECT, comments)

@classmethod
def inform(cls, comments, pylint_comment=None):
return cls(Summary.INFORM, comments)

@classmethod
def summarize_comments(cls, comments, output_file, ideal=False):
comment_types = [item.type.name for item in comments]

# Summarize "optimal" solutions.
if (not comments) and ideal is True:
return Analysis.celebrate(comments).dump(output_file)

if 'ESSENTIAL' in comment_types:
return Analysis.require(comments).dump(output_file)

if 'ACTIONABLE' in comment_types:
return Analysis.direct(comments).dump(output_file)

else:
return Analysis.inform(comments).dump(output_file)


def dump(self, out_path: Path):
"""
Expand Down
56 changes: 53 additions & 3 deletions lib/common/comment.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""
Classes for working with comments.
Classes for working with comments and comment types.
"""
from enum import Enum, unique
from dataclasses import dataclass, field, asdict



@unique
class BaseComments(Enum):
class BaseFeedback(Enum):
"""
Superclass for all analyzers to user to build their Comments.
Superclass for all analyzers to user to build their Feedback.
"""

def __new__(cls, namespace, comment):
Expand All @@ -20,3 +22,51 @@ def __str__(self):

def __repr__(self):
return f"{self.__class__.__name__}.{self.name}"



@unique
class CommentTypes(Enum):
"""
Superclass for all analyzers to us for comment types
"""

CELEBRATORY = 'celebratory'
ESSENTIAL = 'essential'
ACTIONABLE = 'actionable'
INFORMATIVE = 'informative'

def __str__(self):
return self.value.lower()

def __repr__(self):
return f"{self.__class__.__name__}.{self.name}"


@dataclass
class Comment:
comment: str = None
params: dict = field(default_factory=dict)
type: Enum = CommentTypes.INFORMATIVE




@unique
class Summary(Enum):
"""
Summary of the comments for the exercise under analysis.
"""

CELEBRATE = "Congratulations! This solution is very close to ideal! We don't have any specific recommendations."
REQUIRE = "There are a few changes we'd like you to make before completing this exercise."
DIRECT = "There are a few changes we suggest that can bring your solution closer to ideal."
INFORM = "Good work! Here are some general recommendations for improving your Python code."
GENERIC = "We don't have a custom analysis for this exercise yet, but here are some comments from PyLint to help you improve your code."

def __str__(self):
return self.value.lower()

def __repr__(self):
return f"{self.__class__.__name__}.{self.name}"

44 changes: 31 additions & 13 deletions lib/common/exercise.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""
import sys
import importlib
import json
from pathlib import Path
from typing import NamedTuple

Expand All @@ -24,7 +25,7 @@ class Exercise(NamedTuple):
Manages analysis of a an individual Exercise.
"""

name: str
slug: str
in_path: Path
out_path: Path
tests_path: Path
Expand All @@ -34,9 +35,10 @@ def analyzer(self):
"""
The analyzer.py module for this Exercise, imported lazily.
"""
module_name = f"{Exercise.sanitize_name(self.name)}_analyzer"
module_name = f"{Exercise.sanitize_name(self.slug)}_analyzer"

if module_name not in sys.modules:
module = self.available_analyzers()[self.name]
module = self.available_analyzers()[self.slug]
spec = importlib.util.spec_from_file_location(module_name, module)
analyzer = importlib.util.module_from_spec(spec)
spec.loader.exec_module(analyzer)
Expand All @@ -57,11 +59,11 @@ def analyze(self):
return self.analyzer.analyze(self.in_path, self.out_path)

@staticmethod
def sanitize_name(name: str) -> str:
def sanitize_name(slug: str) -> str:
"""
Sanitize an Exercise name (ie "two-fer" -> "two_fer").
"""
return name.replace("-", "_")
return slug.replace("-", "_")

@classmethod
def available_analyzers(cls):
Expand All @@ -71,15 +73,31 @@ def available_analyzers(cls):
return ANALYZERS

@classmethod
def factory(cls, name: str, in_directory: Path, out_directory: Path) -> "Exercise":
def factory(cls, slug: str, in_directory: Path, out_directory: Path) -> "Exercise":
"""
Build an Exercise from its name and the directory where its files exist.
"""
if name not in cls.available_analyzers():
path = LIBRARY.joinpath(name, "analyzer.py")

if slug not in cls.available_analyzers():
path = LIBRARY.joinpath(slug, "analyzer.py")
raise ExerciseError(f"No analyzer discovered at {path}")
sanitized = cls.sanitize_name(name)
in_path = in_directory.joinpath(f"{sanitized}.py").resolve()
out_path = out_directory.joinpath(f"{sanitized}.py").resolve()
tests_path = in_directory.joinpath(f"{sanitized}_test.py").resolve()
return cls(name, in_path, out_path, tests_path)

in_path = None
out_path = None
tests_path = None

config_file = in_directory.joinpath(".meta").joinpath("config.json")

if config_file.is_file():
config_data = json.loads(config_file.read_text())
in_path = in_directory.joinpath(config_data.get('files', {}).get('solution')[0])
out_path = out_directory.joinpath(config_data.get('files', {}).get('solution')[0])
tests_path = in_directory.joinpath(config_data.get('files', {}).get('test')[0])

else:
sanitized = cls.sanitize_name(slug)
in_path = in_directory.joinpath(f"{sanitized}.py").resolve()
out_path = out_directory.joinpath(f"{sanitized}.py").resolve()
tests_path = in_directory.joinpath(f"{sanitized}_test.py").resolve()

return cls(slug, in_path, out_path, tests_path)
40 changes: 40 additions & 0 deletions lib/common/pylint_comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from common.comment import Comment, CommentTypes
from pylint import epylint as lint
from pathlib import Path


def generate_pylint_comments(in_path):
'''
Use Pylint to generate additional feedback comments for code,

e.g. if code follows PEP8 Style Convention
'''

pylint_stdout, _ = lint.py_run(
str(in_path) + ' --score=no --msg-template="{category}, {line}, {msg_id} {symbol}, {msg}"',
return_std=True
)

status_mapping = {
'informational': CommentTypes.INFORMATIVE,
'refactor' : CommentTypes.ACTIONABLE,
'convention' : CommentTypes.ACTIONABLE,
'warning' : CommentTypes.ESSENTIAL,
'error' : CommentTypes.ESSENTIAL,
'fatal' : CommentTypes.ESSENTIAL
}

cleaned_pylint_output = [tuple(item.strip('" ').split(', '))
for item in pylint_stdout.getvalue().splitlines()
if '**' not in item]

pylint_comments = []

for line in cleaned_pylint_output:
if line[0]:
pylint_comments.append(Comment(type=status_mapping[line[0]],
params={'lineno': line[1], 'code': line[2], 'message': line[3]},
comment=f'python.pylint.{line[0]}'))

return pylint_comments

Loading