Open Jelleas opened 10 months ago
(declarative.repl()
.run("from war import Card, Deck, DrawPile")
.stdout("")
.run("deck = Deck()")
.run("assert deck.deal() == Card('A', '2')")
)()
Some initial work:
class repl:
def __init__(self, fileName: Optional[str]=None):
self._initialState: ReplState = ReplState(fileName=fileName)
self._stack: List[Callable[["ReplState"], None]] = []
self._description: Optional[str] = None
init = self.do(lambda replState: ...)
self._stack = init._stack
self.__name__ = init.__name__
self.__doc__ = init.__doc__
def run(self, statement: str) -> Self:
def run(state: ReplState) -> None:
import checkpy.lib
with checkpy.lib.captureStdout() as stdout:
exec(statement, state.env)
state.addStatement(statement, stdout.read())
return self.do(run)
def stdout(self, expected: str) -> Self:
"""Assert that the last statement printed expected."""
def testStdout(state: ReplState):
nonlocal expected
expected = str(expected)
actual = state._stdoutOutputs[-1] # TODO
if expected != actual:
msg = (
"Expected the following output:\n" +
expected +
"\nBut found:\n" +
actual +
"\n"
)
raise AssertionError(
msg +
state.replLog()
)
return self.do(testStdout)
def do(self, function: Callable[["ReplState"], None]) -> Self:
"""
Put function on the internal stack and call it after all previous calls have resolved.
.do serves as an entry point for extensibility. Allowing you, the test writer, to insert
specific and custom asserts, hints, and the like. For example:
def checkDataFileIsUnchanged(state: "FunctionState"):
with open("data.txt") as f:
assert f.read() == "42\\n", "make sure not to change the file data.txt"
testDataUnchanged = test()(function("process_data").call("data.txt").do(checkDataFileIsUnchanged))
```
"""
self = deepcopy(self)
self._stack.append(function)
self.__name__ = f"declarative_repl_test_{uuid4()}"
self.__doc__ = self._description if self._description is not None else self._initialState.description
return self
def __call__(self, test: Optional[checkpy.tests.Test]=None) -> "FunctionState":
"""Run the test."""
if test is None:
test = checkpy.tester.getActiveTest()
initialDescription = ""
if test is not None\
and test.description != test.PLACEHOLDER_DESCRIPTION\
and test.description != self._initialState.description:
initialDescription = test.description
stack = list(self._stack)
state = deepcopy(self._initialState)
for step in stack:
step(state)
if initialDescription:
state.setDescriptionFormatter(lambda descr, state: descr)
state.description = initialDescription
elif state.wasCalled:
state.description = f"{state.getFunctionCallRepr()} works as expected"
else:
state.description = f"{state.name} is correctly defined"
return state
class ReplState: def init(self, fileName: Optional[str]=None): self._description: str = f"TODO" self._fileName = fileName self._isDescriptionMutable: bool = True self._statements: List[str] = [] self._stdoutOutputs: List[str] = [] self.env: dict = {}
@staticmethod
def _descriptionFormatter(descr: str, state: "ReplState") -> str:
return f"testing" + (f" >> {descr}" if descr else "")
@property
def description(self) -> str:
"""The description of the test, what is ultimately shown on the screen."""
return self._descriptionFormatter(self._description, self)
@description.setter
def description(self, newDescription: str):
if not self.isDescriptionMutable:
return
self._description = newDescription
test = checkpy.tester.getActiveTest()
if test is None:
raise checkpy.entities.exception.CheckpyError(
message=f"Cannot change description while there is no test running."
)
test.description = self.description
@property
def fileName(self) -> Optional[str]:
"""
The name of the Python file to run and import.
If this is not set (`None`), the default file (`checkpy.file.name`) is used.
"""
return self._fileName
@fileName.setter
def fileName(self, newFileName: Optional[str]):
self._fileName = newFileName
def addStatement(self, statement: str, stdout: str):
self._statements.append(statement)
self._stdoutOutputs.append(stdout)
@property
def isDescriptionMutable(self):
"""Can the description be changed (mutated)?"""
return self._isDescriptionMutable
@isDescriptionMutable.setter
def isDescriptionMutable(self, newIsDescriptionMutable: bool):
self._isDescriptionMutable = newIsDescriptionMutable
def setDescriptionFormatter(self, formatter: Callable[[str, "ReplState"], str]):
"""
The test's description is formatted by a function accepting the new description and the state.
This method allows you to overwrite that function, for instance:
`state.setDescriptionFormatter(lambda descr, state: f"Testing your function {state.name}: {descr}")`
"""
self._descriptionFormatter = formatter # type:ignore [method-assign, assignment]
test = checkpy.tester.getActiveTest()
if test is None:
raise checkpy.entities.exception.CheckpyError(
message=f"Cannot change descriptionFormatter while there is no test running."
)
test.description = self.description
def replLog(self):
"""
Helper function that formats each line as if it were fed to Python's repl.
"""
def fixLine(line: str) -> str:
line = line.rstrip("\n")
if line.startswith(" "):
return "... " + line
if not line.startswith(">>> "):
return ">>> " + line
return line
# break-up multi-line statements
actualLines = []
for line in self._statements:
actualLines.extend([l for l in line.split("\n") if l])
# prepend >>> and ... (what you'd see in the REPL)
# replace any "assert " statements with "True" on the next line
fixedLines = [fixLine(l) for l in actualLines]
pre = (
'This check failed. Run the following code in the terminal to find out why:\n'
'$ python3\n'
)
return pre + "\n".join(fixedLines)
produces the following output on AssertionError: