Hello everyone, in this blog post, I’ll share a recent exploration I undertook using Python’s introspection features. For those who aren’t familiar, introspection refers to Python’s ability to inspect and know about its own state and objects at runtime. It includes abilities such as determining the type of an object, fetching its attributes, and so on.
In my journey of developing the Jac parser, I had a directory full of example .jac files, and I wanted to write test cases for each one of them without having to manually create a new test function for each file. This is where Python’s introspection feature turned out to be quite useful.
The Challenge
I want a robust protocol for testing the Jac Parser, ensuring that it could accurately parse a large collection of micro Jac files. The standard way of doing this would be to write a test case for each file. However, this becomes incredibly time-consuming and inefficient, especially when the number of files keeps increasing. I needed a way to automatically generate these test cases based on the available files.
The Power of Python’s Introspection
Here’s where Python’s introspection stepped in. It allowed me to dynamically generate test cases by examining the files in my testing directory and creating test methods on the fly. Let’s dive into the details of how I achieved this.
The main player is the setattr()
built-in function, which sets the value of the named attribute of an object. Using this, I could attach new methods to my test class, essentially creating new test cases. Below is the relevant part of my code:
@classmethod def self_attach_micro_tests(cls: "TestParser") -> None: """Attach micro tests.""" directory = os.path.dirname(__file__) + "/fixtures/micro" for filename in os.listdir(directory): if os.path.isfile(os.path.join(directory, filename)) and filename.endswith( ".jac" ): method_name = f"test_micro_{filename.replace('.jac', '')}" setattr(cls, method_name, lambda self, f=filename: self.parse_micro(f))
In this method, I first determine the directory where my example files are located. Then I iterate over all the files in the directory. For each file that ends with “.jac”, I generate a method name using the format test_micro_{filename}
.
Then comes the magic: I use setattr
to add a new method to my class with the generated method name. The new method is a lambda function that calls parse_micro
(see below) with the filename. The lambda function is necessary because I want to generate a new function for each file, not just reuse the same function with different parameters. This way I can get say 100 tests with 100 files (and be able to pin point which test cases are failing).
Invoking the Dynamic Test Attachments
After defining the function to dynamically generate the test cases, the last step was to call this function so that these tests would be available when the test runner starts. I simply invoked TestParser.self_attach_micro_tests()
at the end of the class definition.
The setUp()
method initializes the lexer and parser before every test. Then, for each dynamically created test, parse_micro()
is called to parse the file and ensure there were no errors.
def setUp(self: TestCase) -> None: """Set up test.""" self.lex = JacLexer() self.prse = JacParser() return super().setUp() def parse_micro(self: "TestParser", filename: str) -> None: """Parse micro jac file.""" self.prse.cur_file = filename self.prse.parse(self.lex.tokenize(self.load_fixture(f"micro/{filename}"))) self.assertFalse(self.prse.had_error)
Wrapping Up
The power of Python’s introspection opened up an effective way of generating dynamic test cases. It saved me time, kept the code cleaner, and made it easier to add new test files.
I hope this example encourages you to explore Python’s introspection capabilities and find creative ways to utilize them in your projects. Happy coding!
Full Code Example:
"""Tests for Jac parser.""" import os from jaclang.jac.lexer import JacLexer from jaclang.jac.parser import JacParser from jaclang.utils.test import TestCase class TestParser(TestCase): """Test Jac self.prse.""" def setUp(self: TestCase) -> None: """Set up test.""" self.lex = JacLexer() self.prse = JacParser() return super().setUp() def parse_micro(self: "TestParser", filename: str) -> None: """Parse micro jac file.""" self.prse.cur_file = filename self.prse.parse(self.lex.tokenize(self.load_fixture(f"micro/{filename}"))) self.assertFalse(self.prse.had_error) @classmethod def self_attach_micro_tests(cls: "TestParser") -> None: """Attach micro tests.""" directory = os.path.dirname(__file__) + "/fixtures/micro" for filename in os.listdir(directory): if os.path.isfile(os.path.join(directory, filename)) and filename.endswith( ".jac" ): method_name = f"test_micro_{filename.replace('.jac', '')}" setattr(cls, method_name, lambda self, f=filename: self.parse_micro(f)) TestParser.self_attach_micro_tests()