Skip to content

Commit 480fbb9

Browse files
Added code for loading objects from class references.
1 parent f48389e commit 480fbb9

File tree

3 files changed

+148
-36
lines changed

3 files changed

+148
-36
lines changed

pacai/core/test_board.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def test_load_test_boards(self):
2525
(TEST_BOARD_ERROR_FULL_EMPTY, 'A board cannot be empty.'),
2626
(TEST_BOARD_ERROR_FULL_EMPTY_SEP, 'A board cannot be empty.'),
2727

28-
(TEST_BOARD_ERROR_BAD_CLASS, "Cannot find class 'ZZZ' in module 'pacai.core.board'."),
28+
(TEST_BOARD_ERROR_BAD_CLASS, 'Cannot find class'),
2929

3030
(TEST_BOARD_ERROR_WIDTH_ZERO, 'A board must have at least one column.'),
3131
(TEST_BOARD_ERROR_INCONSISTENT_WIDTH, 'Unexpected width'),

pacai/util/reflection.py

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,19 @@
88
"""
99

1010
import importlib
11+
import importlib.util
1112
import typing
13+
import uuid
1214

1315
CLASS_REF_DELIM: str = ':'
1416

1517
class ClassReference:
1618
"""
1719
A ClassReference is constructed from a formatted that references a specific Python class.
18-
The basic structure of a class reference is: `[<path>:][<qualified package name>.][<module name>.]<class name>`.
19-
This means that a valid class reference for this class can be all of the following:
20-
1) `reflection.ClassReference` -- a module and class name,
21-
2) `pacai.util.reflection.ClassReference` -- a fully qualified name.
22-
3) `pacai/util/reflection.py:ClassReference` -- a path and class name,
23-
4) `pacai/util/reflection.py:pacai.util.reflection.ClassReference` -- a path with a fully qualified name.
24-
25-
Note that a class reference without a package name will need some default package path to look in.
20+
The rough basic structure of a class reference is: `[<path>:][<qualified package name>.][<module name>.]<class name>`.
21+
This means that a valid class reference for this class can either:
22+
1) `pacai.util.reflection.ClassReference` -- a fully qualified name.
23+
2) `pacai/util/reflection.py:ClassReference` -- a path and class name,
2624
"""
2725

2826
def __init__(self, text: str) -> None:
@@ -51,6 +49,12 @@ def __init__(self, text: str) -> None:
5149
if (len(parts) > 1):
5250
module_name = '.'.join(parts[0:-1]).strip()
5351

52+
if ((filepath is not None) and (module_name is not None)):
53+
raise ValueError("Cannot specify both a filepath and module name for class reference: '%s'.", text)
54+
55+
if ((filepath is None) and (module_name is None)):
56+
raise ValueError("Cannot specify a class name alone, need a filepath or module name for class reference: '%s'.", text)
57+
5458
self.filepath: str | None = filepath
5559
""" The filepath component of the class reference (or None). """
5660

@@ -60,29 +64,48 @@ def __init__(self, text: str) -> None:
6064
self.class_name: str = class_name
6165
""" The class_name component of the class reference (or None). """
6266

63-
def new_object(class_name: str, *args, **kwargs) -> typing.Any:
67+
def __str__(self) -> str:
68+
text = self.class_name
69+
70+
if (self.module_name is not None):
71+
text = self.module_name + '.' + text
72+
73+
if (self.filepath is not None):
74+
text = self.filepath + CLASS_REF_DELIM + text
75+
76+
return text
77+
78+
def new_object(class_ref: ClassReference | str, *args, **kwargs) -> typing.Any:
6479
"""
65-
Create a new instance of the specified class,
80+
Create a new instance of the specified class reference,
6681
passing along the args and kwargs.
67-
The class name should be fully qualified, e.g., 'pacai.core.agent.Agent', not just 'Agent'.
68-
69-
The module must be importable (i.e., already in the PATH).
7082
"""
7183

72-
parts = class_name.split('.')
73-
module_name = '.'.join(parts[0:-1])
74-
target_name = parts[-1]
75-
76-
if (len(parts) == 1):
77-
raise ValueError(f"Non-qualified name supplied '{class_name}'.")
84+
if (isinstance(class_ref, str)):
85+
class_ref = ClassReference(class_ref)
7886

79-
try:
80-
module = importlib.import_module(module_name)
81-
except ImportError:
82-
raise ValueError(f"Unable to locate module '{module_name}'.")
87+
module = _import_module(class_ref)
8388

84-
target_class = getattr(module, target_name, None)
89+
target_class = getattr(module, class_ref.class_name, None)
8590
if (target_class is None):
86-
raise ValueError(f"Cannot find class '{target_name}' in module '{module_name}'.")
91+
raise ValueError(f"Cannot find class '{class_ref.class_name}' in class reference '{class_ref}'.")
8792

8893
return target_class(*args, **kwargs)
94+
95+
def _import_module(class_ref):
96+
"""
97+
Import and return the module for the given class reference.
98+
This may involve importing files.
99+
"""
100+
101+
if (class_ref.filepath is not None):
102+
temp_module_name = str(uuid.uuid4()).replace('-', '')
103+
spec = importlib.util.spec_from_file_location(temp_module_name, class_ref.filepath)
104+
module = importlib.util.module_from_spec(spec)
105+
spec.loader.exec_module(module)
106+
return module
107+
else:
108+
try:
109+
return importlib.import_module(class_ref.module_name)
110+
except ImportError:
111+
raise ValueError(f"Unable to locate module '{class_ref.module_name}'.")

pacai/util/test_reflection.py

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1+
import os
2+
13
import pacai.core.agent
24
import pacai.test.base
35
import pacai.util.reflection
46

7+
THIS_DIR: str = os.path.join(os.path.dirname(os.path.realpath(__file__)))
8+
59
class TicketTest(pacai.test.base.BaseTest):
610
def test_class_reference_base(self):
711
# [(text, expected error substring, (filename, module_name, class_name)), ...]
812
test_cases = [
9-
(
10-
'ClassReference',
11-
None,
12-
(None, None, 'ClassReference'),
13-
),
1413
(
1514
'reflection.ClassReference',
1615
None,
@@ -21,11 +20,6 @@ def test_class_reference_base(self):
2120
None,
2221
(None, 'pacai.util.reflection', 'ClassReference'),
2322
),
24-
(
25-
'pacai/util/reflection.py:pacai.util.reflection.ClassReference',
26-
None,
27-
('pacai/util/reflection.py', 'pacai.util.reflection', 'ClassReference'),
28-
),
2923
(
3024
'pacai/util/reflection.py:ClassReference',
3125
None,
@@ -54,6 +48,16 @@ def test_class_reference_base(self):
5448
'without a class name',
5549
None,
5650
),
51+
(
52+
'ClassReference',
53+
'Cannot specify a class name alone',
54+
None,
55+
),
56+
(
57+
'pacai/util/reflection.py:pacai.util.reflection.ClassReference',
58+
'both a filepath and module name',
59+
None,
60+
),
5761
]
5862

5963
for i in range(len(test_cases)):
@@ -73,3 +77,88 @@ def test_class_reference_base(self):
7377

7478
actual_parts = (class_ref.filepath, class_ref.module_name, class_ref.class_name)
7579
self.assertEqual(expected_parts, actual_parts)
80+
81+
def test_new_object_base(self):
82+
# [(class_ref, expected error substring, args, kwargs, expected_count), ...]
83+
test_cases = [
84+
(
85+
'pacai.util.test_reflection.TestClass',
86+
None,
87+
[],
88+
{},
89+
0,
90+
),
91+
(
92+
'pacai/util/test_reflection.py:TestClass',
93+
None,
94+
[],
95+
{},
96+
0,
97+
),
98+
(
99+
'pacai.util.test_reflection.TestClass',
100+
None,
101+
[1],
102+
{},
103+
1,
104+
),
105+
(
106+
'pacai.util.test_reflection.TestClass',
107+
None,
108+
[],
109+
{'count': 2},
110+
2,
111+
),
112+
(
113+
'pacai.util.test_reflection.TestClass',
114+
None,
115+
[],
116+
{'other': 3},
117+
0,
118+
),
119+
(
120+
'pacai.util.test_reflection.TestClass',
121+
None,
122+
[4],
123+
{'other': 5},
124+
4,
125+
),
126+
127+
# Errors
128+
129+
(
130+
'reflection.ClassReference',
131+
"Unable to locate module 'reflection'",
132+
[],
133+
{},
134+
None,
135+
),
136+
(
137+
'pacai.util.test_reflection.TestClass',
138+
'got multiple values for argument',
139+
[1],
140+
{'count': 2},
141+
None,
142+
),
143+
]
144+
145+
for i in range(len(test_cases)):
146+
(class_ref, error_substring, args, kwargs, expected_count) = test_cases[i]
147+
with self.subTest(msg = f"Case {i}:"):
148+
try:
149+
actual = pacai.util.reflection.new_object(class_ref, *args, **kwargs)
150+
except Exception as ex:
151+
if (error_substring is None):
152+
self.fail(f"Unexpected error: '{str(ex)}'.")
153+
154+
self.assertIn(error_substring, str(ex), 'Error is not as expected.')
155+
continue
156+
157+
if (error_substring is not None):
158+
self.fail(f"Did not get expected error: '{error_substring}'.")
159+
160+
self.assertEqual(expected_count, actual.count)
161+
162+
class TestClass:
163+
def __init__(self, count = 0, **kwargs):
164+
self.count = count

0 commit comments

Comments
 (0)