Repository: optapy/optapy (language: python)

Issue:
Quickstarts School Timetabling throws error when running
I recently built a wheel from the latest commit of [optapy](https://github.com/optapy/optapy/commit/7dec406e10a8e2835ac3eefa42d9b1e9430a12e6) and installed it into the virtual environment in optapy-quickstarts.  However, when I try to run the `school-timetabling` example, I get the following error:

```python
16:55:13.418 [main        ] INFO  Solving started: time spent (113), best score (-38init/0hard/0soft), environment mode (REPRODUCIBLE), move thread count (NONE), random (JDK with seed 0).
Traceback (most recent call last):
  File "DefaultSolver.java", line 193, in org.optaplanner.core.impl.solver.DefaultSolver.solve
java.lang.java.lang.ClassCastException: java.lang.ClassCastException: class org.optaplanner.jpyinterpreter.types.PythonNone cannot be cast to class org.jpyinterpreter.user.domain.Timeslot$$2 (org.optaplanner.jpyinterpreter.types.PythonNone is in unnamed module of loader 'app'; org.jpyinterpreter.user.domain.Timeslot$$2 is in unnamed module of loader 'OptaPlanner Gizmo Python Bytecode ClassLoader' @3632be31)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "DefaultSolver.java", line 193, in org.optaplanner.core.impl.solver.DefaultSolver.solve
Exception: Java Exception

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/user/workspaces/optapy-quickstarts/.venv/lib/python3.10/site-packages/optapy/optaplanner_api_wrappers.py", line 419, in solve
    return _unwrap_java_object(self._java_solve(wrapped_problem))
java.lang.java.lang.IllegalStateException: java.lang.IllegalStateException: The property (timeslot) setterMethod (public void org.jpyinterpreter.user.domain.Lesson$$2.setTimeslot(org.jpyinterpreter.user.domain.Timeslot$$2)) on bean of class (class org.jpyinterpreter.user.domain.Lesson$$2) throws an exception for value (null).

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/user/workspaces/optapy-quickstarts/school-timetabling/main.py", line 80, in <module>
    solution = solver.solve(generate_problem())
  File "/Users/user/workspaces/optapy-quickstarts/.venv/lib/python3.10/site-packages/optapy/optaplanner_api_wrappers.py", line 426, in solve
    raise RuntimeError(error_message) from e
RuntimeError: An error occurred during solving. This can occur when functions take the wrong number of parameters (ex: a setter that does not take exactly one parameter) or by a function returning an incompatible return type (ex: returning a str in a filter, which expects a bool). This can also occur when an exception is raised when evaluating constraints/getters/setters.
```

I'll enumerate my procedure, as I may be making a mistake with building the wheel and installing into optapy-quickstarts which produces this error.

1. Clone optapy. From the optapy root directory, `./mvnw clean install` and `python -m build`
2. Copy optapy-8.31.1b0-py3-none-any.whl to optapy-quickstarts/school-timetabling directory
3. From optapy-quickstarts/school-timetabling, run `pip install optapy-8.31.1b0-py3-none-any.whl` then `python main.py` 


Discussion / hints:
This is related to https://github.com/optapy/optapy/pull/152 , although it appears it passing None although NoneType is not assignable to Timeslot; changing the type annotation on Lesson to:
```python
    timeslot: Optional[Timeslot]
    room: Optional[Room]
```
avoids the issue; my guess it is because the Timeslot and Room are set to None in the constructor (despite not being assignable to None from type annotations) 
So the `Optional[]` typing is required because (1) this is a non-nullable planning variable and (2) it's initialized to None?  It seems like that's a very normal initialization for most under-constrained planning problems.
Would you mind clarifying how typing is used and the assumptions that `jpyinterpreter` has regarding `None`?  I couldn't find anything in the documentation about it and it appears to be essential to correct function even for non-decorated funtions/classes.  Are all typing declarations used literally by `jpyinterpreter`?
  Typing declarations are assumed to be correct by `jpyinterpreter`, and are used to change generic method calls (which have to go through `__getattribute__` lookups) into specific method calls (this significantly increase speed, for school timetabling, without the Optional, it three times faster because of the specific method calls). That being said, planning variables should be allowed to take the value None even if they are not annotated Optional (this assumes simple getters and setters, which changes it from setting it to None to deleting the attribute).

This is due to how Python None works, it cannot be treated as null (i.e. `isinstance(None, x)` is false for all x except NoneType = type(None); whereas `isinstance(null, x) `is true for all x in Java; this means we cannot assign None to attributes that are not Optional).

Fix patch:
diff --git a/optapy-core/src/main/java/org/optaplanner/optapy/PythonWrapperGenerator.java b/optapy-core/src/main/java/org/optaplanner/optapy/PythonWrapperGenerator.java
index 4ac8fe4..46e50f9 100644
--- a/optapy-core/src/main/java/org/optaplanner/optapy/PythonWrapperGenerator.java
+++ b/optapy-core/src/main/java/org/optaplanner/optapy/PythonWrapperGenerator.java
@@ -832,7 +832,7 @@ public static Class<?> definePlanningSolutionClass(String className, Class<?> pa
     }
 
     // Used for debugging; prints a result handle
-    private static void print(MethodCreator methodCreator, ResultHandle toPrint) {
+    private static void print(BytecodeCreator methodCreator, ResultHandle toPrint) {
         ResultHandle out = methodCreator.readStaticField(FieldDescriptor.of(System.class, "out", PrintStream.class));
         methodCreator.invokeVirtualMethod(MethodDescriptor.ofMethod(PrintStream.class, "println", void.class, Object.class),
                 out, toPrint);
@@ -1865,7 +1865,14 @@ private static FieldDescriptor generateWrapperMethod(ClassCreator classCreator,
             try {
                 Method setterMethod = lookupMethod(parentClass, PythonClassTranslator.getJavaMethodName(setterMethodName));
 
-                if (setterMethod.getParameterTypes()[0].isAssignableFrom(PythonNone.class)) {
+                Class<?> fieldType;
+                try {
+                    fieldType = parentClass.getField(PythonClassTranslator.getJavaFieldName(fieldName)).getType();
+                } catch (NoSuchFieldException e) {
+                    fieldType = null;
+                }
+                if ((fieldType == null || fieldType.isAssignableFrom(PythonNone.class))
+                        && setterMethod.getParameterTypes()[0].isAssignableFrom(PythonNone.class)) {
                     // valueAsPythonLikeObject might be null if the getter returns a PythonLikeObject
                     BranchResult isNullBranchResult = setterMethodCreator.ifNull(valueAsPythonLikeObject);
                     BytecodeCreator currentBranch = isNullBranchResult.trueBranch();
@@ -1888,6 +1895,7 @@ private static FieldDescriptor generateWrapperMethod(ClassCreator classCreator,
                     currentBranch = isNoneBranchResult.falseBranch();
                     currentBranch.invokeSpecialMethod(MethodDescriptor.ofMethod(setterMethod), currentBranch.getThis(),
                             valueAsPythonLikeObject);
+
                     currentBranch = isNoneBranchResult.trueBranch();
                     currentBranch.invokeSpecialMethod(MethodDescriptor.ofMethod(setterMethod), currentBranch.getThis(),
                             currentBranch.loadNull());


Test patch:
diff --git a/tests/test_domain.py b/tests/test_domain.py
index 1cd05c8..743fecb 100644
--- a/tests/test_domain.py
+++ b/tests/test_domain.py
@@ -3,7 +3,9 @@
 import optapy.score
 import optapy.config
 import optapy.constraint
+
 import dataclasses
+from typing import Optional
 
 
 def test_solve_partial():
@@ -97,16 +99,23 @@ def set_score(self, score):
 
 def test_solve_nullable():
     class Code:
+        value: str
+
         def __init__(self, value):
             self.value = value
 
     @optapy.problem_fact
     class Value:
+        code: Code
+
         def __init__(self, code):
             self.code = Code(code)
 
     @optapy.planning_entity
     class Entity:
+        code: Code
+        value: Optional[Value]
+
         def __init__(self, code, value=None):
             self.code = Code(code)
             self.value = value
@@ -188,6 +197,94 @@ def set_score(self, score):
     assert solution.entities[1].value == v1 or solution.entities[1].value == None
 
 
+def test_solve_typed():
+    class Code:
+        value: str
+
+        def __init__(self, value: str):
+            self.value = value
+
+    @optapy.problem_fact
+    class Value:
+        code: Code
+
+        def __init__(self, code: str):
+            self.code = Code(code)
+
+    @optapy.planning_entity
+    class Entity:
+        code: Code
+        value: Value
+
+        def __init__(self, code, value=None):
+            self.code = Code(code)
+            self.value = value
+
+        @optapy.planning_variable(Value, value_range_provider_refs=['value_range'])
+        def get_value(self):
+            return self.value
+
+        def set_value(self, value):
+            self.value = value
+
+    def assign_to_v1(constraint_factory: optapy.constraint.ConstraintFactory):
+        return (constraint_factory.for_each(Entity)
+                .filter(lambda e: e.value.code.value == 'v1')
+                .reward('assign to v1', optapy.score.SimpleScore.ONE)
+                )
+
+    @optapy.constraint_provider
+    def my_constraints(constraint_factory: optapy.constraint.ConstraintFactory):
+        return [
+            assign_to_v1(constraint_factory)
+        ]
+
+    @optapy.planning_solution
+    class Solution:
+        def __init__(self, entities, values, score=None):
+            self.entities = entities
+            self.values = values
+            self.score = score
+
+        @optapy.planning_entity_collection_property(Entity)
+        def get_entities(self):
+            return self.entities
+
+        @optapy.problem_fact_collection_property(Value)
+        @optapy.value_range_provider(range_id='value_range')
+        def get_values(self):
+            return self.values
+
+        @optapy.planning_score(optapy.score.SimpleScore)
+        def get_score(self) -> optapy.score.SimpleScore:
+            return self.score
+
+        def set_score(self, score):
+            self.score = score
+
+    solver_config = optapy.config.solver.SolverConfig()
+    termination_config = optapy.config.solver.termination.TerminationConfig()
+    termination_config.setBestScoreLimit('2')
+    solver_config.withSolutionClass(Solution) \
+        .withEntityClasses(Entity) \
+        .withConstraintProviderClass(my_constraints) \
+        .withTerminationConfig(termination_config)
+
+    e1 = Entity('e1')
+    e2 = Entity('e2')
+
+    v1 = Value('v1')
+    v2 = Value('v2')
+
+    problem = Solution([e1, e2], [v1, v2])
+    solver = optapy.solver_factory_create(solver_config).buildSolver()
+    solution = solver.solve(problem)
+
+    assert solution.score.getScore() == 2
+    assert solution.entities[0].value == v1
+    assert solution.entities[1].value == v1
+
+
 def test_single_property():
     @optapy.planning_entity
     class Entity: