Skip to content

Commit 97bf0f7

Browse files
chore: Delegate log events to Python logger (#65)
- This allows Python to configure their own appenders in Python (and use the standard Python appenders), abeit at a performance penalty. However, since DEBUG and lower should not be used when performance is required, log messages are produced only when solving started/ended, which is infrequent. - Tracking the log level of 'timefold.solver' would work, except we need to also track its parent logger, since their level affect the effective log level. Thus, we need to 'refresh' the log level whenever solving starts (which is the only time the Java side would emit log messages).
1 parent 4bbe022 commit 97bf0f7

File tree

12 files changed

+219
-77
lines changed

12 files changed

+219
-77
lines changed

tests/test_logging.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import logging
2+
from timefold.solver import *
3+
from timefold.solver.domain import *
4+
from timefold.solver.config import *
5+
from timefold.solver.score import *
6+
7+
from dataclasses import dataclass, field
8+
from typing import Annotated, List
9+
10+
11+
@planning_entity
12+
@dataclass
13+
class Entity:
14+
code: Annotated[str, PlanningId]
15+
value: Annotated[int, PlanningVariable] = field(default=None, compare=False)
16+
17+
18+
@constraint_provider
19+
def my_constraints(constraint_factory: ConstraintFactory):
20+
return [
21+
constraint_factory.for_each(Entity)
22+
.reward(SimpleScore.ONE, lambda entity: entity.value)
23+
.as_constraint('Maximize value'),
24+
]
25+
26+
27+
@planning_solution
28+
@dataclass
29+
class Solution:
30+
entities: Annotated[List[Entity], PlanningEntityCollectionProperty]
31+
value_range: Annotated[List[int], ValueRangeProvider]
32+
score: Annotated[SimpleScore, PlanningScore] = field(default=None)
33+
34+
def __str__(self) -> str:
35+
return str(self.entities)
36+
37+
38+
def assert_in_logs(log_messages: list[str], message: str):
39+
for log_message in log_messages:
40+
if message in log_message:
41+
return
42+
raise AssertionError(f'Expected message {message} in {log_messages}, but it was not found.')
43+
44+
def test_log_events_are_forwarded(caplog):
45+
with caplog.at_level(logging.INFO, logger="timefold.solver"):
46+
solver_config = SolverConfig(
47+
solution_class=Solution,
48+
entity_class_list=[Entity],
49+
score_director_factory_config=ScoreDirectorFactoryConfig(
50+
constraint_provider_function=my_constraints,
51+
),
52+
termination_config=TerminationConfig(
53+
best_score_limit='9'
54+
)
55+
)
56+
solver_factory = SolverFactory.create(solver_config)
57+
solver = solver_factory.build_solver(SolverConfigOverride(
58+
termination_config=TerminationConfig(
59+
best_score_limit='3'
60+
)
61+
))
62+
problem = Solution([Entity('A')], [1, 2, 3])
63+
solver.solve(problem)
64+
log_messages = [rec.message for rec in caplog.records]
65+
# INFO
66+
assert_in_logs(log_messages, 'Solving started')
67+
# INFO
68+
assert_in_logs(log_messages, 'Solving ended')
69+
70+
71+
def test_support_changing_log_levels(caplog):
72+
with caplog.at_level(logging.DEBUG, logger="timefold.solver"):
73+
solver_config = SolverConfig(
74+
solution_class=Solution,
75+
entity_class_list=[Entity],
76+
score_director_factory_config=ScoreDirectorFactoryConfig(
77+
constraint_provider_function=my_constraints,
78+
),
79+
termination_config=TerminationConfig(
80+
best_score_limit='9'
81+
)
82+
)
83+
solver_factory = SolverFactory.create(solver_config)
84+
solver = solver_factory.build_solver(SolverConfigOverride(
85+
termination_config=TerminationConfig(
86+
best_score_limit='3'
87+
)
88+
))
89+
problem = Solution([Entity('A')], [1, 2, 3])
90+
solver.solve(problem)
91+
log_messages = [rec.message for rec in caplog.records]
92+
# INFO
93+
assert_in_logs(log_messages, 'Solving started')
94+
# DEBUG
95+
assert_in_logs(log_messages, 'CH step (0)')
96+
# INFO
97+
assert_in_logs(log_messages, 'Solving ended')
98+
99+
100+
def test_support_trace_logs(caplog):
101+
# Python has no logging.TRACE, logging.DEBUG = 10, so TRACE = 5
102+
with caplog.at_level(5, logger="timefold.solver"):
103+
solver_config = SolverConfig(
104+
solution_class=Solution,
105+
entity_class_list=[Entity],
106+
score_director_factory_config=ScoreDirectorFactoryConfig(
107+
constraint_provider_function=my_constraints,
108+
),
109+
termination_config=TerminationConfig(
110+
best_score_limit='9'
111+
)
112+
)
113+
solver_factory = SolverFactory.create(solver_config)
114+
solver = solver_factory.build_solver(SolverConfigOverride(
115+
termination_config=TerminationConfig(
116+
best_score_limit='3'
117+
)
118+
))
119+
problem = Solution([Entity('A')], [1, 2, 3])
120+
solver.solve(problem)
121+
log_messages = [rec.message for rec in caplog.records]
122+
# INFO
123+
assert_in_logs(log_messages, 'Solving started')
124+
# DEBUG
125+
assert_in_logs(log_messages, 'CH step (0)')
126+
# TRACE/5
127+
assert_in_logs(log_messages, 'Move index (1)')
128+
# INFO
129+
assert_in_logs(log_messages, 'Solving ended')

timefold-solver-python-core/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@
3535
<groupId>ch.qos.logback</groupId>
3636
<artifactId>logback-classic</artifactId>
3737
</dependency>
38+
<dependency>
39+
<groupId>ch.qos.logback</groupId>
40+
<artifactId>logback-core</artifactId>
41+
</dependency>
3842
<dependency>
3943
<groupId>org.slf4j</groupId>
4044
<artifactId>slf4j-api</artifactId>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package ai.timefold.solver.python.logging;
2+
3+
import java.util.function.Consumer;
4+
5+
import ch.qos.logback.classic.spi.ILoggingEvent;
6+
import ch.qos.logback.core.AppenderBase;
7+
8+
public class PythonDelegateAppender extends AppenderBase<ILoggingEvent> {
9+
private static Consumer<PythonLoggingEvent> logEventConsumer;
10+
11+
public static void setLogEventConsumer(Consumer<PythonLoggingEvent> logEventConsumer) {
12+
PythonDelegateAppender.logEventConsumer = logEventConsumer;
13+
}
14+
15+
@Override
16+
protected void append(ILoggingEvent eventObject) {
17+
logEventConsumer.accept(
18+
new PythonLoggingEvent(
19+
PythonLogLevel.fromJavaLevel(eventObject.getLevel()),
20+
eventObject.getFormattedMessage()));
21+
}
22+
}

timefold-solver-python-core/src/main/java/ai/timefold/solver/python/logging/PythonLogLevel.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public enum PythonLogLevel {
88
WARNING(30, Level.WARN),
99
INFO(20, Level.INFO),
1010
DEBUG(10, Level.DEBUG),
11+
TRACE(5, Level.TRACE),
1112
NOTSET(0, Level.INFO);
1213

1314
final int pythonLevelNumber;
@@ -26,6 +27,24 @@ public Level getJavaLogLevel() {
2627
return javaLogLevel;
2728
}
2829

30+
public static PythonLogLevel fromJavaLevel(Level level) {
31+
// Check INFO and ERROR first, since they have multiple corresponding
32+
// Python levels
33+
if (level.equals(Level.INFO)) {
34+
return INFO;
35+
} else if (level.equals(Level.ERROR)) {
36+
return ERROR;
37+
} else {
38+
int levelNumber = level.toInt();
39+
for (PythonLogLevel pythonLogLevel : PythonLogLevel.values()) {
40+
if (pythonLogLevel.getJavaLogLevel().toInt() == levelNumber) {
41+
return pythonLogLevel;
42+
}
43+
}
44+
throw new IllegalStateException("Unmatched log level (" + level + ") with level number (" + level.toInt() + ").");
45+
}
46+
}
47+
2948
public static PythonLogLevel fromPythonLevelNumber(int levelNumber) {
3049
PythonLogLevel bestMatch = PythonLogLevel.CRITICAL;
3150
int bestMatchLevelNumber = 50;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package ai.timefold.solver.python.logging;
2+
3+
public record PythonLoggingEvent(PythonLogLevel level, String message) {
4+
}

timefold-solver-python-core/src/main/java/ai/timefold/solver/python/logging/PythonLoggingToLogbackAdapter.java

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,14 @@
22

33
import org.slf4j.LoggerFactory;
44

5-
import ch.qos.logback.classic.Level;
65
import ch.qos.logback.classic.Logger;
76
import ch.qos.logback.classic.LoggerContext;
87

98
public class PythonLoggingToLogbackAdapter {
9+
private static final Logger LOGGER = ((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger("ai.timefold");
1010

11-
private static Logger getLogger(String loggerName) {
12-
LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
13-
return loggerContext.getLogger(loggerName);
14-
}
15-
16-
public static void setLevel(String loggerName, int logLevel) {
17-
Logger logger = getLogger(loggerName);
11+
public static void setLevel(int logLevel) {
1812
PythonLogLevel pythonLogLevel = PythonLogLevel.fromPythonLevelNumber(logLevel);
19-
logger.setLevel(pythonLogLevel.getJavaLogLevel());
20-
}
21-
22-
public static boolean isEnabledFor(String loggerName, int logLevel) {
23-
Logger logger = getLogger(loggerName);
24-
PythonLogLevel pythonLogLevel = PythonLogLevel.fromPythonLevelNumber(logLevel);
25-
return logger.isEnabledFor(pythonLogLevel.getJavaLogLevel());
26-
}
27-
28-
public static int getEffectiveLevel(String loggerName) {
29-
Logger logger = getLogger(loggerName);
30-
Level effectiveLogLevel = logger.getEffectiveLevel();
31-
return effectiveLogLevel.levelInt / 1000;
13+
LOGGER.setLevel(pythonLogLevel.getJavaLogLevel());
3214
}
3315
}

timefold-solver-python-core/src/main/python/_jpype_type_conversions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ def defineConstraints(self, constraint_factory):
1313
return self.delegate(constraint_factory)
1414

1515

16+
@JImplements('java.util.function.Consumer', deferred=True)
17+
class PythonConsumer:
18+
def __init__(self, delegate):
19+
self.delegate = delegate
20+
21+
@JOverride
22+
def accept(self, item):
23+
return self.delegate(item)
24+
25+
1626
@JImplements('java.util.function.Supplier', deferred=True)
1727
class PythonSupplier:
1828
def __init__(self, delegate):

timefold-solver-python-core/src/main/python/_solver.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from ._problem_change import ProblemChange, ProblemChangeWrapper
2+
from ._timefold_java_interop import update_log_level
23
from typing import TypeVar, TYPE_CHECKING, Generic, Callable
34
from datetime import timedelta
45
from jpype import JClass, JImplements, JOverride
@@ -102,6 +103,7 @@ def solve(self, problem: Solution_):
102103
raise ValueError(
103104
f'The problem ({problem}) is not an instance of the @planning_solution class ({self._solution_class})'
104105
)
106+
update_log_level()
105107
try:
106108
java_solution = self._delegate.solve(java_problem)
107109
except PythonBaseException as e:

timefold-solver-python-core/src/main/python/_solver_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .config import SolverConfigOverride
33
from ._solver_factory import SolverFactory
44
from ._future import wrap_future
5+
from ._timefold_java_interop import update_log_level
56

67
from typing import Awaitable, TypeVar, Generic, Callable, TYPE_CHECKING
78
from datetime import timedelta
@@ -317,6 +318,7 @@ def run(self) -> SolverJob[Solution_, ProblemId_]:
317318
SolverJob
318319
The `SolverJob` built from this `SolverJobBuilder`.
319320
"""
321+
update_log_level()
320322
return SolverJob(self._delegate.run())
321323

322324

timefold-solver-python-core/src/main/python/_timefold_java_interop.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1+
import logging
12
import pathlib
23
import jpype
34
import jpype.imports
45
from jpype.types import *
56
import importlib.resources
67
from typing import cast, TypeVar, Callable, Union, TYPE_CHECKING, Any
7-
from ._jpype_type_conversions import PythonSupplier, ConstraintProviderFunction
8+
from ._jpype_type_conversions import PythonSupplier, ConstraintProviderFunction, PythonConsumer
89

910
if TYPE_CHECKING:
1011
# These imports require a JVM to be running, so only import if type checking
1112
from java.lang import ClassLoader
1213
from ai.timefold.solver.core.api.score.stream import (Constraint as _Constraint,
1314
ConstraintFactory as _ConstraintFactory)
15+
from ai.timefold.solver.python import PythonLoggingEvent
16+
17+
logger = logging.getLogger('timefold.solver')
1418

1519
Solution_ = TypeVar('Solution_')
1620
ProblemId_ = TypeVar('ProblemId_')
@@ -49,7 +53,7 @@ def extract_timefold_jars() -> list[str]:
4953
if p.name.endswith(".jar")] + enterprise_dependencies
5054

5155

52-
def init(*args, path: list[str] = None, include_timefold_jars: bool = True, log_level='INFO') -> None:
56+
def init(*args, path: list[str] = None, include_timefold_jars: bool = True) -> None:
5357
"""
5458
Most users will never need to call this method.
5559
Only call this method if you are using other Java libraries.
@@ -66,10 +70,9 @@ def init(*args, path: list[str] = None, include_timefold_jars: bool = True, log_
6670
include_timefold_jars : bool, optional
6771
If the Timefold dependencies should be added to `path`.
6872
Defaults to True.
69-
log_level : str
70-
The logging level to use.
7173
"""
7274
from _jpyinterpreter import init
75+
7376
if jpype.isJVMStarted(): # noqa
7477
raise RuntimeError('JVM already started. Maybe call init before timefold.solver.types imports?')
7578
if path is None:
@@ -78,11 +81,23 @@ def init(*args, path: list[str] = None, include_timefold_jars: bool = True, log_
7881
if include_timefold_jars:
7982
path = path + extract_timefold_jars()
8083
if len(args) == 0:
81-
args = (jpype.getDefaultJVMPath(), '-Dlogback.level.ai.timefold={}'.format(log_level)) # noqa
82-
else:
83-
args = args + ('-Dlogback.level.ai.timefold={}'.format(log_level),)
84+
args = [jpype.getDefaultJVMPath()]
8485
init(*args, path=path, include_translator_jars=False)
8586

87+
from ai.timefold.solver.python.logging import PythonLoggingToLogbackAdapter, PythonDelegateAppender
88+
PythonDelegateAppender.setLogEventConsumer(PythonConsumer(forward_logging_events))
89+
update_log_level()
90+
91+
92+
def update_log_level() -> None:
93+
from ai.timefold.solver.python.logging import PythonLoggingToLogbackAdapter
94+
PythonLoggingToLogbackAdapter.setLevel(logger.getEffectiveLevel())
95+
96+
97+
def forward_logging_events(event: 'PythonLoggingEvent') -> None:
98+
logger.log(event.level().getPythonLevelNumber(),
99+
event.message())
100+
86101

87102
def ensure_init():
88103
"""

timefold-solver-python-core/src/main/python/_timefold_python_logger.py

Lines changed: 0 additions & 42 deletions
This file was deleted.

0 commit comments

Comments
 (0)