Fixing anti-patterns#1039
Conversation
The `-X dev` option reveals useful information such as leaked system resources.
61ac746 to
ab37ab5
Compare
Forgot this.
At minimum this converts the usage to the documented approach where this is exactly one asyncio event loop (started in main) and everything that currently is written async actually becomes a coroutine. The unit tests which use coroutines now use `IsolatedAsyncioTestCase` instead of just `TestCase`, which ensures they continue to work.
Instead of aliasing this function, we should just rename it entirely. Instead of delegating the parsing of an `argparse.Namespace` object, we just setup the `runbook` argument to be a `Path` instead of a string, and have `load_runbook` accept what it needs instead of digging it out of the `Namespace`.
And use single underscore to avoid invoking name mangling. If the name is reused in a subclass, we likely do not intend to have a duplicate variable, but are intending to use it. For variables we expect to be used, we should use Python’s built-in `property` class/decorator.
It was not being used for anything useful.
This was an abstract class that provided no value, just extra abstraction. Originally it was going to be used by multiple runner implementations, and the notifier, and for artifacts. However, we only have one runner which no longer uses it, the notifier never used it, and artifacts aren’t implemented so it’s too early to say if it’s useful there. Hence we should delete this to reduce complexity.
Since there is no longer a corresponding `stop` (and when there was a `stop` it didn’t actually do anything).
Since it’s really just a list comprehension. Also add a bunch of notes identifying things to investigate further.
These functions did not need a class, they can run be themselves. There’s some work to fix the functions which still rely entirely on side-effects, but this reduces a lot of complexity (as evidenced by the changes in the tests, half of which no longer generate a `Runner` instance at all).
Since it now always expects an `env_runbook` we can just construct it on-the-fly.
Because an implicit alphabetical dependency in the test module names breaks the tests.
eb05bdf to
b333442
Compare
There’s more work to do because this was using duck typing. It’s a little better now, but it’s just a massive wrapper for a library that’s just another wrapper for Python’s actual APIs (which are just wrappers for the system’s APIs).
Since we’re reusing the unittest module’s `TestCase` class, we should not introduce confusing new nomenclature like `TestSuite`, since a “suite” is different from a “case” in usage.
6f4196a to
806f662
Compare
Also rename (and delete where appropriate) the respective functions (and start cleaning them up, though they need more work).
And start updating other functions.
`LisaTestCase` is _not_ an abstract base class, it’s just a base class (with a lot of implementation) and defines no abstract methods. So we remove this.
Again for consistency.
By using @DataClass. Also, this actually a case for multiple inheritance instead of abuse of `__getattr__`.
|
@andschwa see comments inline. Thank you a lot! It's not only an improvement of code base, also give me a chance to explain "what's in my mind". I know I said it many times, but do nothing. I will prioritize my document work, it's definitely a tech debt now.
|
| * Always use `cls` for the first argument to class methods. | ||
| * Use one leading underscore only for non-public methods and instance variables, | ||
| such as `_data`. | ||
| such as `_data`. Do not activate name mangling with `__` unless necessary. |
There was a problem hiding this comment.
as pep8, _name uses to mark "subclass API". __ is for private in this case.
| * If there is a pair of `get_x` and `set_x` methods, they should instead be a | ||
| proper property, which is easy to do with the built-in `@property` decorator. | ||
| * Constants should be `CAPITALIZED_SNAKE_CASE`. | ||
| * When importing a function, try to avoid renaming it with `import as` because |
There was a problem hiding this comment.
It's my previous experience, and can be fixed and document. for example, load function in runbook, it should be imported like from lisa.parameter_parser import runbook, and used like runbook.load. So that it doesn't need as, and clear on using.
| * Constants should be `CAPITALIZED_SNAKE_CASE`. | ||
| * When importing a function, try to avoid renaming it with `import as` because | ||
| it introduces cognitive overhead to track yet another name. | ||
| * When deriving another module’s class (such as `unittest.TestCase`), reuse the |
There was a problem hiding this comment.
As my explanation in overall comments, unittest.TestCase is a mixin actually. We can raise another case to explain this point.
|
|
||
|
|
||
| @TestSuiteMetadata( | ||
| @LisaTestCaseMetadata( |
There was a problem hiding this comment.
Keep class -> suite, and method -> case. See my explanation in overall comments, we have different concept on them than Python ut. We're not write UT, so the concept of suite and case are heavier than Python UT.
|
Hi @squirrelsc, please take a look at this demo I constructed over the weekend. I intend to demo it and explain its design at today's sync meeting: #1044 |
Glad you appreciated the review! I just wish we'd been able to do it before merging this code originally. It is so important for us to get the design right, I just don't think we've done that yet.
I don't believe this is strictly true. As implemented, it appears many designs were borrowed from
I disagree with this. The logic to collect and run tests is a solved problem for which we do not need to (nor do we want to) implement yet another test framework. Our logic is no different than any other test framework, it just has our own specifics. Can you elaborate in what our logic is so different that we have to abandon all other test frameworks?
That's fine, but it's not something we need to reimplement an entire framework to accomplish. It can be implemented in a simpler manner.
My point here is not that there is one bug, it's that there's so much tightly coupled code that it was impossible to change a simple thing (the name of a test file). This feels like a legacy codebase with the amount of tech debt that's already built up.
That means it should be optionally set not optionally defined, which is my original point. The actual defined attributes of the class are changing at runtime, which will (and already does) lead to lots of maintenance headaches.
The point is the same as above. The class is not fully defined, ever. It's shape (what fields and types of those fields it has) changes at runtime. This is not defensive programming and defeats the point of us having any type checking to begin with.
I understand what a decorator is and how it works. Again, the point is the same as above, the class itself is not fully defined because the code is defining attributes (not setting their values) at runtime.
This again ties into the above point. The static type analysis is pointing out the inherent issue above: that the classes are not fully defined. Instead of fixing this and ensuring the classes were fully defined, an attempt was made to hide the type error by introducing this dead code. The function is never called, instead the type is defined and its value set for each instance of the class at runtime within three nested function calls from the point of the decorator being used.
I think the
If we are redefining them we should clearly document such, but I could not find that. Instead we are mixing nomenclatures, and not explaining our new one. I also see no reason to diverge from an existing an well known nomenclature that we have also brought into the codebase.
That is precisely why we are taking our time to review this code carefully and decide if it's what we want. After using it, we've discovered that we don't want
Please don't rely on internally shared documents for the documentation of an open source project. While I have reviewed those slides, they did not help me understand the design decisions of the implementation that I reviewed here.
I think you are realizing my own point here: these designs are common because test discovery, filtering, and running are solved problems. Let's not reinvent the wheel.
I posit that this codebase as it stands is not maintainable. It's in about the same state as LISAv2. The framework is what's important for us to get right, because it's from the framework that everything else will be accomplished.
Hm?
That's fine, and I think a laudable goal we can accomplish, but it doesn't mean we need to use this code.
I'm not sure I agree that this is good idea. What we're talking about here is a platform abstraction layer, which is separate from a test framework. Many (many, many) PALs have been written, and they're all encumbered and difficult to maintain. If we implement any part of a PAL, we should only do so when we have enough code (read: tests) doing the same thing that it would actually benefit us to refactor it, not just because we (like everyone before us) thought it was a good idea and wanted it. I think it will cost us more than it will benefit us and urge caution against writing yet another PAL.
Let's focus first on lifting-and-shifting the existing LISAv2 tests before we write a new requirements framework. We need the existing tests moved first in order to correctly reason about what a requirements/capability framework should look like, it's not something we should just guess at.
Ah, yes, plugins are great. Pytest is written to make plugins easy.
I'm sorry, I'm struggling to follow this. Why are there so many places to replace the test variables? I think user's should have exactly one place to set their variables, and that should be as close to the test itself (so in the test's metadata, however that's implemented).
Thanks! I think the Pytest based demo implements the majority of what we've implemented here, with 2.3% of the code. Source lines of code for 7459 (total) - 1935 (tests) = 5,524 Source lines of code for 127 (total including example tests) 127/5,524 = 2.3% |
| class HelloWorld(TestSuite): | ||
| @TestCaseMetadata( | ||
| class HelloWorld(LisaTestCase): | ||
| @LisaTestMetadata( |
There was a problem hiding this comment.
The Test is confusing. Test means much more than test case, it's hard to understand Test is subitem of Test case.
| "TestSuiteMetadata", | ||
| "TestCaseMetadata", | ||
| "TestSuite", | ||
| "LisaTestCase", |
There was a problem hiding this comment.
I need some guidances what kind of things worth to have a shortcut in __init__.
| try: | ||
| exit_code = main() | ||
| # TODO: Turn off debugging when we ship this. | ||
| exit_code = asyncio.run(main(), debug=True) |
There was a problem hiding this comment.
Can it be loaded from debug flag or something else? It's not a reliable way. We may need this after shipped.
| load_from_env(variables) | ||
| if hasattr(args, "variables"): | ||
| load_from_pairs(args.variables, variables) | ||
| load_from_pairs(user_variables, variables) |
There was a problem hiding this comment.
I hit errors if no variable defined. how it's fixed?
| ) | ||
| from lisa.util.logger import get_logger | ||
|
|
||
| log = get_logger("runner") |
|
|
||
| if not isinstance(self._process, ExecutableResult): | ||
| if self._result is None: | ||
| # if not isinstance(self._process, ExecutableResult): |
| self._process = result | ||
| else: | ||
| result = self._process | ||
| # TODO: The spur library is not very good and leaves open |
|
|
||
| def is_running(self) -> bool: | ||
| if self._running: | ||
| if self._running and self._process: |
There was a problem hiding this comment.
How about set _running to False also, when _process is cleaned up?
| total_elapsed: float = 0 | ||
|
|
||
|
|
||
| class Action(metaclass=ABCMeta): |
There was a problem hiding this comment.
I will add artifact builder soon. It handles a lot of things before environment provisioning, and user may want to see progress and full picture in a diagram. That action can help tracking progress and provide such a picture. Anyway, I can add it later, when we implementing test case management portal.
| use_new_environment=case_use_new_env, | ||
| ) | ||
| ], | ||
| environment=generate_runbook(**kwargs), |
There was a problem hiding this comment.
Move it into the construction will hide default value of schema.Runbook. The environment is optional, so that default value should be covered also. The platform is mandatory, so it's in construction.
Thank you, Andy! I will do cherry pick on this PR, to remove user experience, and project management related changes.
|
Cherry picked in #1069 . Thank you, Andy! |
This is a work-in-progress, but I am trying to fix anti-patterns as I find them (and then document best practices in the contributing guidelines) and clean up internal documentation etc. However, at this point I don't think this work will ever be finished.
As I got further into reviewing the prototype, I am unsure if this is what we want. There's a lot of code that's reimplementing a unit test framework (like collecting and running tests, e.g. all of the metadata decorator class logic), which is something many libraries already do (see
unittestandpytest). There's also a lot of difficult dependencies already. For instance:test_lisarunner.pytotest_runner.pybecause doing so breaks the tests. The global variables intest_platform.pyend up as empty lists, breaking an assertion in the runner tests. This seems to be caused by an implicit alphabetical dependency based on the module loading sequence, but I couldn't fix it.LisaTestMetadatainto a dataclass, because it's not actually fully defined as a class. It hasself.requirementandself.suitedefined at runtime. There are two big issues here creating a difficult-to-maintain codebase: the class abuses__getattr__to pretend to inherit theLisaTestCaseMetadataclass, by forwarding attributes toself.suiteif they don't exist. Hence whyself.requirementisn't optionally set, it's optionally defined as an attribute in the first place. There is also an issue withself.suiteitself, where it's not defined except at runtime as the classes are instantiated through their decorator__call__attribute (and at that, in a function of a function). This causes obvious typing errors, and instead of designing to avoid such an issue, dead code was introduced in the form ofset_suite(self)which is never called.LisaRunner(Action)where theActionclass does nothing of value, and theLisaRunnerclass really only matters for itsstartfunction. This same kind of pattern repeats itself throughout the code.TestSuiteclass derives fromunittest.TestCase, which implies that we actually are usingunittestfor all its value (such as discovering and running tests), but this is not the case. We're simply reusing its assertions and reimplementing everything else. It's very confusing. This also introduced mixed nomenclature: Since we're (kind of) reusing the unittest module’sTestCaseclass, we should not introduce confusing new nomenclature likeTestSuite, since a "suite" is different from a "case" in usage.unittestmodule (based on name choices precisely matching their own modules), but since we're not leveragingunittest, I can't really say.There's a lot more that I discovered, and frankly we are already heavily violating two of our main design goals:
I'm considering going back to the drawing board, because I know that we need to not repeat the mistakes of LISAv2, and this must be maintainable. I have a tentative plan drafted to make LISAv3 a plugin for
pytest, but am still working on it.Update:
Here's a working demo using Pytest instead. (Note that my machine has SSH config setup to connect to a node via the name "centos", which won't work for you out of the box.)