Skip to content

Commit d0bc8aa

Browse files
authored
Merge pull request #6 from atellaluca/feature/spymodel-property-variable-support
feat: add support for module variables and class/instance attributes in SpyModel and update docs
2 parents 1349de1 + f976fef commit d0bc8aa

File tree

7 files changed

+123
-41
lines changed

7 files changed

+123
-41
lines changed

README.rst

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,23 +60,40 @@ Here's a simple example showing how to use **ImportSpy** to validate that an imp
6060
from importspy.models import SpyModel, ClassModel
6161
from typing import List
6262
63-
# Define the rules for how your Python code should be used
63+
# Define the rules for how your Python code should be structured and used by external modules
6464
class MyLibrarySpy(SpyModel):
65-
functions: List[str] = ["required_function"] # Required function in the importing module
65+
# List of required variables that must be present in the importing module
66+
variables: List[str] = ["required_var1", "required_var2"] # Required variables
67+
68+
# List of required functions that must be defined in the importing module
69+
functions: List[str] = ["required_function"] # Required function
70+
71+
# Define the required classes, their attributes, and methods
6672
classes: List[ClassModel] = [
6773
ClassModel(
68-
name="MyRequiredClass", # Required class name
69-
methods=["required_method1", "required_method2"] # Required methods in the class
74+
name="MyRequiredClass", # Name of the required class
75+
76+
# Required class-level attributes (e.g., static variables)
77+
class_attr=["attr_1", "attr_2"], # Class attributes
78+
79+
# Required instance-level attributes (must exist in the class instances)
80+
instance_attr=["attr_3"], # Instance attributes
81+
82+
# Methods that the required class must implement
83+
methods=["required_method1", "required_method2"] # Required methods
7084
)
7185
]
7286
73-
# Check if the importing module complies with the rules
87+
# Use ImportSpy to check if the importing module complies with the defined rules
7488
module = Spy().importspy(spymodel=MyLibrarySpy)
7589
90+
# If the module passes validation, you can safely use it; otherwise, ImportSpy raises an error
7691
if module:
77-
print(f"Module {module.__name__} is using your library correctly!")
92+
print(f"Module '{module.__name__}' complies with the specified rules and is ready to use!")
7893
else:
79-
print("The importing module is not complying with the rules.")
94+
print("The importing module does not comply with the required structure.")
95+
96+
# Now you can access all the attributes of the module that imports your code
8097
8198
Example of a Compliant Importing Python Module
8299
==============================================

examples/plugin_based_architecture/extension.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import package
22
from plugin_interface import Plugin
33

4+
plugin_name = "plugin name"
5+
plugin_description = "plugin description"
46
class Extension(Plugin):
7+
8+
extension_name = "extension_name"
9+
10+
def __init__(self) -> None:
11+
self.extension_instance_name = "extension_instance_name"
512

613
def add_extension(self):
714
print("Extension has added")

examples/plugin_based_architecture/package.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
from typing import List
55

66
class PluginSpy(SpyModel):
7+
variables: List[str] = ["plugin_name", "plugin_description"]
78
classes: List[ClassModel] = [
89
ClassModel(
10+
class_attr=["extension_name"],
11+
instance_attr=["extension_instance_name"],
912
methods=["add_extension", "remove_extension", "http_get_request"],
1013
superclasses=["Plugin"]
1114
),

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "importspy"
3-
version = "0.1.7"
3+
version = "0.1.8"
44
description = "ImportSpy is a lightweight Python library that enables proactive control over how your code is used when imported by other modules or packages. It allows developers to define rules that importing modules must follow, ensuring proper use and preventing misuse or errors."
55
license = "MIT"
66
authors = ["Luca Atella <atellaluca@outlook.it>"]

src/importspy/models.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ class ClassModel(BaseModel):
1717
1818
## Attributes:
1919
- **name** (`Optional[str]`): The name of the class. This can be `None` if the class name is unavailable. Defaults to `None`.
20+
- **class_attr* (`Optional[List[str]]`): A list of class attributes names that are expected to be present in the class. Defaults to an empty list.
21+
- *instance_attr* (`Optional[List[str]]`): A list of class isntance attributes names that are expected to be present in the class. Defaults to an empty list.
2022
- **methods** (`Optional[List[str]]`): A list of method names that are expected to be present in the class. Defaults to an empty list.
2123
- **superclasses** (`Optional[List[str]]`): A list of names of the superclasses from which this class inherits. Defaults to an empty list.
2224
@@ -32,6 +34,8 @@ class ClassModel(BaseModel):
3234
and inherit from `BaseClass`. External modules importing your code can then be validated to follow this structure.
3335
"""
3436
name: Optional[str] = None
37+
class_attr: Optional[List[str]] = []
38+
instance_attr: Optional[List[str]] = []
3539
methods: Optional[List[str]] = []
3640
superclasses: Optional[List[str]] = []
3741

@@ -47,6 +51,7 @@ class SpyModel(BaseModel):
4751
## Attributes:
4852
- **filename** (`Optional[str]`): The name of the module file. If the name is unavailable, it can be `None`. Defaults to `None`.
4953
- **version** (`Optional[str]`): The expected version of the module. Defaults to `None` if the version is not available.
54+
- **variables** (`Optional[List[str]]`): A list of variables names that are expected to be defined within the module. Defaults to an empty list.
5055
- **functions** (`Optional[List[str]]`): A list of function names that are expected to be defined within the module. Defaults to an empty list.
5156
- **classes** (`Optional[List[ClassModel]]`): A list of class definitions within the module, represented by `ClassModel`. Defaults to an empty list.
5257
@@ -84,8 +89,9 @@ class SpyModel(BaseModel):
8489
This will extract the necessary metadata from `my_module` and create a `SpyModel` instance that describes the module's
8590
structure, which can then be compared to the expected structure defined by the developer.
8691
"""
87-
filename: Optional[str] = None
88-
version: Optional[str] = None
92+
filename: Optional[str] = ""
93+
version: Optional[str] = ""
94+
variables: Optional[List[str]] = []
8995
functions: Optional[List[str]] = []
9096
classes: Optional[List[ClassModel]] = []
9197

@@ -118,17 +124,30 @@ def from_module(cls, info_module: ModuleType):
118124
logger.debug(f"Create SpyModel from info_module: {ModuleType}")
119125
filename = "/".join(info_module.__file__.split('/')[-1:])
120126
version = spy_module_utils.extract_version(info_module)
127+
variables = spy_module_utils.extract_variables(info_module)
121128
functions = spy_module_utils.extract_functions(info_module)
122129
classes = [
123-
ClassModel(name=name, methods=methods, superclasses=superclasses)
124-
for name, methods, superclasses in spy_module_utils.extract_classes(info_module)
130+
ClassModel(name=name,
131+
class_attr=class_attr,
132+
instance_attr=instance_attr,
133+
methods=methods,
134+
superclasses=superclasses)
135+
for
136+
name,
137+
class_attr,
138+
instance_attr,
139+
methods,
140+
superclasses
141+
in spy_module_utils.extract_classes(info_module)
125142
]
126143
spy_module_utils.unload_module(info_module)
127144
logger.debug("Unload module")
128-
logger.debug(f"filename: {filename}, version: {version}, functions: {functions}, classes: {classes}")
145+
logger.debug(f"filename: {filename}, version: {version}, \
146+
functions: {functions}, classes: {classes}")
129147
return cls(
130148
filename=filename,
131149
version=version,
150+
variables=variables,
132151
functions=functions,
133152
classes=classes
134153
)

src/importspy/utils/spy_model_utils.py

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,33 +35,29 @@ def is_subset(spy_model_1: SpyModel, spy_model_2: SpyModel) -> bool:
3535
This function helps ensure that any importing module respects the rules set by the developer,
3636
reducing the risk of improper usage and integration issues.
3737
"""
38-
39-
# Check if the filenames match, if provided
4038
if spy_model_1.filename and spy_model_1.filename != spy_model_2.filename:
4139
return False
42-
43-
# Check if the versions match, if provided
4440
if spy_model_1.version and spy_model_1.version != spy_model_2.version:
4541
return False
46-
47-
# Check if all functions in spy_model_1 are present in spy_model_2
42+
if spy_model_1.variables:
43+
if not set(spy_model_1.variables).issubset(spy_model_2.variables):
44+
return False
4845
if spy_model_1.functions:
4946
if not set(spy_model_1.functions).issubset(spy_model_2.functions):
5047
return False
51-
52-
# Check if all classes in spy_model_1 are present in spy_model_2
5348
for class_1 in spy_model_1.classes:
54-
# Find the corresponding class in spy_model_2
5549
class_2 = next((cls for cls in spy_model_2.classes if cls.name == class_1.name or class_1.name is None), None)
5650
if not class_2:
5751
return False
58-
59-
# Check if all methods in class_1 are present in class_2
52+
if class_1.class_attr:
53+
if not set(class_1.class_attr).issubset(class_2.class_attr):
54+
return False
55+
if class_1.instance_attr:
56+
if not set(class_1.instance_attr).issubset(class_2.instance_attr):
57+
return False
6058
if class_1.methods:
6159
if not set(class_1.methods).issubset(class_2.methods):
6260
return False
63-
64-
# Check if all superclasses in class_1 are present in class_2
6561
if class_1.superclasses:
6662
if not set(class_1.superclasses).issubset(class_2.superclasses):
6763
return False

src/importspy/utils/spy_module_utils.py

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@
1010
logger = logging.getLogger(__name__)
1111
logger.addHandler(logging.NullHandler())
1212

13-
ClassInfo = namedtuple('ClassInfo', ['name', 'methods', 'superclasses'])
13+
ClassInfo = namedtuple('ClassInfo', [
14+
'name',
15+
'class_attr',
16+
'instance_attr',
17+
'methods',
18+
'superclasses'
19+
])
1420

1521
def inspect_module() -> tuple:
1622
"""
@@ -135,54 +141,88 @@ def extract_version(info_module: ModuleType) -> str | None:
135141
except importlib.metadata.PackageNotFoundError:
136142
return None
137143

138-
def extract_functions(info_module: ModuleType) -> List[str] | None:
144+
def extract_variables(info_module:ModuleType) -> List[str]:
139145
"""
140-
Extract a list of function names defined in a module, ensuring that external modules adhere to
141-
the expected function structure.
146+
Extract a list of variables names defined in a module.
147+
148+
This function retrieves all variables names defined within the specified module, allowing
149+
developers to verify the variables defined in the module.
150+
151+
Parameters:
152+
-----------
153+
- **info_module** (`ModuleType`): The module from which to extract function names.
154+
155+
Returns:
156+
--------
157+
- **List[str]: A list of variables names defined in the module.
158+
"""
159+
return [
160+
var_name
161+
for var_name, obj in inspect.getmembers(info_module)
162+
if not callable(obj)
163+
and not isinstance(obj, ModuleType)
164+
and not (var_name.startswith("__") and var_name.endswith("__"))
165+
]
166+
167+
168+
def extract_functions(info_module: ModuleType) -> List[str]:
169+
"""
170+
Extract a list of function names defined in a module.
142171
143172
This function retrieves all function names defined within the specified module, allowing
144-
developers to verify the functions implemented in the module and ensure they align with expectations.
173+
developers to verify the functions implemented in the module.
145174
146175
Parameters:
147176
-----------
148177
- **info_module** (`ModuleType`): The module from which to extract function names.
149178
150179
Returns:
151180
--------
152-
- **List[str] | None**: A list of function names defined in the module, or `None` if no functions are found.
181+
- **List[str]: A list of function names defined in the module.
153182
"""
154-
functions = [
183+
return [
155184
func_name
156185
for func_name, obj in inspect.getmembers(info_module, inspect.isfunction)
157186
if obj.__module__ == info_module.__name__
158187
]
159-
return functions
160188

161189
def extract_classes(info_module: ModuleType) -> List[ClassInfo]:
162190
"""
163-
Extract information about classes in a module, including their methods and superclasses,
164-
providing a clear picture of the module's class structure.
191+
Extract information about classes in a module, including their methods, class attributes,
192+
instance attributes, and superclasses, providing a clear picture of the module's class structure.
165193
166194
This function retrieves class information from the specified module, including class names,
167-
the methods they define, and their superclasses. This can be used to ensure that the module's
168-
class structure adheres to predefined expectations.
195+
class attributes, instance attributes, methods they define, and their superclasses.
169196
170197
Parameters:
171198
-----------
172199
- **info_module** (`ModuleType`): The module from which to extract class information.
173200
174201
Returns:
175202
--------
176-
- **List[ClassInfo]**: A list of `ClassInfo` namedtuples containing class names, methods, and superclasses.
203+
- **List[ClassInfo]**: A list of `ClassInfo` namedtuples containing class names, class_attr, instance_attr, methods, and superclasses.
177204
"""
178205
classes = []
179206
for class_name, cls_obj in inspect.getmembers(info_module, inspect.isclass):
180207
if cls_obj.__module__ == info_module.__name__:
208+
class_attr = [
209+
attr_name for attr_name, attr_value in cls_obj.__dict__.items()
210+
if not callable(attr_value) and not attr_name.startswith('__')
211+
]
212+
instance_attr = []
213+
init_method = cls_obj.__dict__.get('__init__')
214+
if init_method:
215+
source_lines = inspect.getsourcelines(init_method)[0]
216+
for line in source_lines:
217+
line = line.strip()
218+
if line.startswith('self.') and '=' in line:
219+
attr_name = line.split('=')[0].strip().split('.')[1]
220+
instance_attr.append(attr_name)
181221
methods = [
182222
method_name for method_name, _ in inspect.getmembers(cls_obj, inspect.isfunction)
183223
]
184224
superclasses = [base.__name__ for base in cls_obj.__bases__]
185-
current_class = ClassInfo(class_name, methods, superclasses)
225+
current_class = ClassInfo(class_name, class_attr, instance_attr, methods, superclasses)
186226
classes.append(current_class)
187227
return classes
188228

@@ -211,4 +251,4 @@ def extract_superclasses(module: ModuleType) -> List[str]:
211251
superclasses.add(base.__name__)
212252
logger.debug(f"Added {base.__name__} to superclasses set")
213253
logger.debug(f"Superclasses: {superclasses}")
214-
return list(superclasses)
254+
return list(superclasses)

0 commit comments

Comments
 (0)