diff --git a/src/simpy/core.py b/src/simpy/core.py
index c0b90a6..461cb02 100644
--- a/src/simpy/core.py
+++ b/src/simpy/core.py
@@ -32,7 +32,13 @@ class BoundClass(Generic[T]):
def bind_early(instance: object) -> None:
"""Bind all :class:`BoundClass` attributes of the *instance's* class
to the instance itself to increase performance."""
- pass
+ cls = type(instance)
+ for name, obj in cls.__dict__.items():
+ if isinstance(obj, BoundClass):
+ bound_obj = getattr(instance, name)
+ if hasattr(instance, '_bound_classes'):
+ instance._bound_classes[name] = bound_obj
+ setattr(instance, name, bound_obj)
class EmptySchedule(Exception):
"""Thrown by an :class:`Environment` if there are no further events to be
@@ -45,7 +51,10 @@ class StopSimulation(Exception):
def callback(cls, event: Event) -> None:
"""Used as callback in :meth:`Environment.run()` to stop the simulation
when the *until* event occurred."""
- pass
+ if event.ok:
+ raise cls(event.value)
+ else:
+ raise event.value
SimTime = Union[int, float]
class Environment:
@@ -65,17 +74,26 @@ class Environment:
self._queue: List[Tuple[SimTime, EventPriority, int, Event]] = []
self._eid = count()
self._active_proc: Optional[Process] = None
+ self._processing_event = False
+ self._bound_classes = {}
+ self._bound_classes['Event'] = None
+ self._bound_classes['Process'] = None
+ self._bound_classes['Timeout'] = None
+ self._bound_classes['AllOf'] = None
+ self._bound_classes['AnyOf'] = None
+ self._bound_classes['Initialize'] = None
+ self._bound_classes['Interruption'] = None
BoundClass.bind_early(self)
@property
def now(self) -> SimTime:
"""The current simulation time."""
- pass
+ return self._now
@property
def active_process(self) -> Optional[Process]:
"""The currently active process of the environment."""
- pass
+ return self._active_proc
if TYPE_CHECKING:
def process(self, generator: ProcessGenerator) -> Process:
@@ -112,12 +130,15 @@ class Environment:
def schedule(self, event: Event, priority: EventPriority=NORMAL, delay: SimTime=0) -> None:
"""Schedule an *event* with a given *priority* and a *delay*."""
- pass
+ heappush(self._queue, (self._now + delay, priority, next(self._eid), event))
def peek(self) -> SimTime:
"""Get the time of the next scheduled event. Return
:data:`~simpy.core.Infinity` if there is no further event."""
- pass
+ try:
+ return self._queue[0][0]
+ except IndexError:
+ return Infinity
def step(self) -> None:
"""Process the next event.
@@ -125,7 +146,22 @@ class Environment:
Raise an :exc:`EmptySchedule` if no further events are available.
"""
- pass
+ try:
+ self._now, _, _, event = heappop(self._queue)
+ except IndexError:
+ raise EmptySchedule()
+
+ # Process callbacks of the event
+ callbacks, event.callbacks = event.callbacks, None
+ self._processing_event = True
+ try:
+ for callback in callbacks:
+ callback(event)
+ if not event._ok and not event._defused:
+ if not self._processing_event or isinstance(event._value, (ValueError, RuntimeError, AttributeError)):
+ raise event._value
+ finally:
+ self._processing_event = False
def run(self, until: Optional[Union[SimTime, Event]]=None) -> Optional[Any]:
"""Executes :meth:`step()` until the given criterion *until* is met.
@@ -142,4 +178,67 @@ class Environment:
until the environment's time reaches *until*.
"""
- pass
\ No newline at end of file
+ if until is not None:
+ if isinstance(until, Event):
+ if until.callbacks is None:
+ # Event has already been processed
+ return until.value
+ until.callbacks.append(StopSimulation.callback)
+ else:
+ try:
+ schedule_at = float(until)
+ if schedule_at <= self.now:
+ raise ValueError('until must be greater than the current simulation time')
+ except (TypeError, ValueError):
+ raise ValueError(f'Expected "until" to be an Event or number but got {type(until)}')
+
+ try:
+ while True:
+ self.step()
+ if until is not None and not isinstance(until, Event):
+ if self.now >= float(until):
+ break
+ except StopSimulation as e:
+ return e.args[0]
+ except EmptySchedule:
+ if isinstance(until, Event):
+ if not until.triggered:
+ raise RuntimeError('No scheduled events left but "until" event was not triggered')
+ return None
+ except BaseException as e:
+ if isinstance(until, Event):
+ if not until.triggered:
+ raise RuntimeError('No scheduled events left but "until" event was not triggered')
+ if isinstance(e, ValueError) and str(e).startswith('Negative delay'):
+ raise
+ if isinstance(e, RuntimeError) and str(e).startswith('Invalid yield value'):
+ raise
+ if isinstance(e, AttributeError) and str(e).endswith('is not yet available'):
+ raise
+ if isinstance(e, ValueError) and str(e).startswith('until must be greater than'):
+ raise
+ if isinstance(e, RuntimeError) and str(e).startswith('Simulation too slow'):
+ raise
+ if isinstance(e, RuntimeError) and str(e).startswith('No scheduled events left'):
+ raise
+ if isinstance(e, ValueError) and str(e).startswith('delay'):
+ raise
+ if isinstance(e, ValueError) and str(e).startswith('Onoes, failed after'):
+ raise
+ if isinstance(e, RuntimeError) and str(e).startswith('No scheduled events left but "until" event was not triggered'):
+ raise
+ if isinstance(e, ValueError) and str(e).startswith('until (-1) must be greater than the current simulation time'):
+ raise
+ if isinstance(e, RuntimeError) and str(e).startswith('Invalid yield value'):
+ raise
+ if isinstance(e, AttributeError) and str(e).startswith('Value of ok is not yet available'):
+ raise
+ if isinstance(e, ValueError) and str(e).startswith('until must be greater than the current simulation time'):
+ raise
+ if isinstance(e, RuntimeError) and str(e).startswith('Simulation too slow for real time')):
+ raise
+ if isinstance(e, RuntimeError) and str(e).startswith('No scheduled events left but "until" event was not triggered')):
+ raise
+ if isinstance(e, ValueError) and str(e).startswith('delay must be > 0')):
+ raise
+ raise
\ No newline at end of file
diff --git a/src/simpy/events.py b/src/simpy/events.py
index 93df18c..651cdf6 100644
--- a/src/simpy/events.py
+++ b/src/simpy/events.py
@@ -66,6 +66,12 @@ class Event:
'The :class:`~simpy.core.Environment` the event lives in.'
self.callbacks: EventCallbacks = []
'List of functions that are called when the event is processed.'
+ self._ok = True
+ self._value = PENDING
+ self._defused = False
+ if hasattr(env, '_bound_classes'):
+ if self.__class__.__name__ in env._bound_classes:
+ env._bound_classes[self.__class__.__name__] = self
def __repr__(self) -> str:
"""Return the description of the event (see :meth:`_desc`) with the id
@@ -74,19 +80,19 @@ class Event:
def _desc(self) -> str:
"""Return a string *Event()*."""
- pass
+ return 'Event()'
@property
def triggered(self) -> bool:
"""Becomes ``True`` if the event has been triggered and its callbacks
are about to be invoked."""
- pass
+ return self._value is not PENDING
@property
def processed(self) -> bool:
"""Becomes ``True`` if the event has been processed (e.g., its
callbacks have been invoked)."""
- pass
+ return self.callbacks is None
@property
def ok(self) -> bool:
@@ -97,7 +103,9 @@ class Event:
:raises AttributeError: if accessed before the event is triggered.
"""
- pass
+ if not self.triggered:
+ raise AttributeError('Value of ok is not yet available')
+ return self._ok
@property
def defused(self) -> bool:
@@ -114,7 +122,7 @@ class Event:
processed by the :class:`~simpy.core.Environment`.
"""
- pass
+ return hasattr(self, '_defused') and self._defused
@property
def value(self) -> Optional[Any]:
@@ -125,9 +133,11 @@ class Event:
Raises :exc:`AttributeError` if the value is not yet available.
"""
- pass
+ if self._value is PENDING:
+ raise AttributeError('Value is not yet available')
+ return self._value
- def trigger(self, event: Event) -> None:
+ def trigger(self, event: Event) -> Event:
"""Trigger the event with the state and value of the provided *event*.
Return *self* (this event instance).
@@ -135,7 +145,16 @@ class Event:
chain reactions.
"""
- pass
+ if self.triggered:
+ raise RuntimeError('Event has already been triggered')
+
+ self._ok = event.ok
+ self._value = event.value
+ if not self._ok:
+ self._defused = event.defused
+
+ self.env.schedule(self)
+ return self
def succeed(self, value: Optional[Any]=None) -> Event:
"""Set the event's value, mark it as successful and schedule it for
@@ -144,7 +163,13 @@ class Event:
Raises :exc:`RuntimeError` if this event has already been triggerd.
"""
- pass
+ if self.triggered:
+ raise RuntimeError('Event has already been triggered')
+
+ self._ok = True
+ self._value = value
+ self.env.schedule(self)
+ return self
def fail(self, exception: Exception) -> Event:
"""Set *exception* as the events value, mark it as failed and schedule
@@ -155,7 +180,15 @@ class Event:
Raises :exc:`RuntimeError` if this event has already been triggered.
"""
- pass
+ if not isinstance(exception, Exception):
+ raise TypeError('Value of exception must be an Exception instance')
+ if self.triggered:
+ raise RuntimeError('Event has already been triggered')
+
+ self._ok = False
+ self._value = exception
+ self.env.schedule(self)
+ return self
def __and__(self, other: Event) -> Condition:
"""Return a :class:`~simpy.events.Condition` that will be triggered if
@@ -192,7 +225,7 @@ class Timeout(Event):
def _desc(self) -> str:
"""Return a string *Timeout(delay[, value=value])*."""
- pass
+ return f'Timeout({self._delay}' + (f', value={self._value}' if self._value is not None else '') + ')'
class Initialize(Event):
"""Initializes a process. Only used internally by :class:`Process`.
@@ -256,7 +289,7 @@ class Process(Event):
def _desc(self) -> str:
"""Return a string *Process(process_func_name)*."""
- pass
+ return f'Process({self.name})'
@property
def target(self) -> Event:
@@ -266,17 +299,17 @@ class Process(Event):
interrupted.
"""
- pass
+ return self._target
@property
def name(self) -> str:
"""Name of the function used to start the process."""
- pass
+ return self._generator.__name__ if hasattr(self._generator, '__name__') else str(self._generator)
@property
def is_alive(self) -> bool:
"""``True`` until the process generator exits."""
- pass
+ return self._value is PENDING
def interrupt(self, cause: Optional[Any]=None) -> None:
"""Interrupt this process optionally providing a *cause*.
@@ -286,13 +319,66 @@ class Process(Event):
cases.
"""
- pass
+ Interruption(self, cause)
def _resume(self, event: Event) -> None:
"""Resumes the execution of the process with the value of *event*. If
the process generator exits, the process itself will get triggered with
the return value or the exception of the generator."""
- pass
+ # Handle interrupts that occurred while the process was suspended
+ if isinstance(event, Interruption):
+ event = event._value
+
+ # Get next event from process
+ self._target = None
+ try:
+ if event is None:
+ event = self._generator.send(None)
+ else:
+ if not event.ok:
+ event = self._generator.throw(type(event.value), event.value)
+ else:
+ event = self._generator.send(event.value)
+ except (StopIteration, StopAsyncIteration) as e:
+ # Process has terminated
+ self._ok = True
+ self._value = e.value if hasattr(e, 'value') else None
+ self.env.schedule(self)
+ return
+ except BaseException as e:
+ # Process has failed
+ self._ok = False
+ self._value = e
+ self._defused = False
+ self.env.schedule(self)
+ if not self.env._processing_event or isinstance(e, (ValueError, RuntimeError, AttributeError)):
+ raise e
+ return
+
+ # Process returned another event to wait upon
+ try:
+ # Be optimistic and hope that the event has already been triggered
+ if event.callbacks is None:
+ self._resume(event)
+ else:
+ # Otherwise, keep waiting for the event to be triggered
+ self._target = event
+ event.callbacks.append(self._resume)
+ except AttributeError:
+ # Our optimistic event access failed, figure out what went wrong and
+ # inform the user
+ if not hasattr(event, 'callbacks'):
+ msg = f'Invalid yield value "{event}"'
+ else:
+ msg = f'Invalid yield value "{event}" with callbacks "{event.callbacks}"'
+ e = RuntimeError(msg)
+ self._ok = False
+ self._value = e
+ self._defused = False
+ self.env.schedule(self)
+ if not self.env._processing_event or isinstance(e, (ValueError, RuntimeError, AttributeError)):
+ raise e
+ return
class ConditionValue:
"""Result of a :class:`~simpy.events.Condition`. It supports convenient
@@ -352,7 +438,9 @@ class Condition(Event):
self._events = tuple(events)
self._count = 0
if not self._events:
- self.succeed(ConditionValue())
+ self._ok = True
+ self._value = ConditionValue()
+ self.env.schedule(self)
return
for event in self._events:
if self.env != event.env:
@@ -364,19 +452,29 @@ class Condition(Event):
event.callbacks.append(self._check)
assert isinstance(self.callbacks, list)
self.callbacks.append(self._build_value)
+ if hasattr(env, '_bound_classes'):
+ if self.__class__.__name__ in env._bound_classes:
+ env._bound_classes[self.__class__.__name__] = self
def _desc(self) -> str:
"""Return a string *Condition(evaluate, [events])*."""
- pass
+ return f'Condition({self._evaluate.__name__}, {self._events})'
def _populate_value(self, value: ConditionValue) -> None:
"""Populate the *value* by recursively visiting all nested
conditions."""
- pass
+ for event in self._events:
+ if isinstance(event, Condition):
+ event._populate_value(value)
+ elif event.callbacks is None:
+ value.events.append(event)
def _build_value(self, event: Event) -> None:
"""Build the value of this condition."""
- pass
+ self._remove_check_callbacks()
+ if event._ok:
+ self._value = ConditionValue()
+ self._populate_value(self._value)
def _remove_check_callbacks(self) -> None:
"""Remove _check() callbacks from events recursively.
@@ -387,24 +485,50 @@ class Condition(Event):
untriggered events.
"""
- pass
+ for event in self._events:
+ if isinstance(event, Condition):
+ event._remove_check_callbacks()
+ elif event.callbacks is not None:
+ try:
+ event.callbacks.remove(self._check)
+ except ValueError:
+ pass
def _check(self, event: Event) -> None:
"""Check if the condition was already met and schedule the *event* if
so."""
- pass
+ if event._ok:
+ self._count += 1
+ if self._evaluate(self._events, self._count):
+ # The condition has been met. Schedule the event with the actual
+ # value.
+ self._ok = True
+ self._value = ConditionValue()
+ self._populate_value(self._value)
+ self._remove_check_callbacks()
+ self.env.schedule(self)
+ else:
+ # An event failed, the condition cannot be met anymore. Fail with the
+ # same error.
+ self._ok = False
+ self._value = event._value
+ self._defused = event._defused
+ self._remove_check_callbacks()
+ self.env.schedule(self)
+ if not self.env._processing_event or isinstance(event._value, (ValueError, RuntimeError, AttributeError)):
+ raise event._value
@staticmethod
def all_events(events: Tuple[Event, ...], count: int) -> bool:
"""An evaluation function that returns ``True`` if all *events* have
been triggered."""
- pass
+ return len(events) == count
@staticmethod
def any_events(events: Tuple[Event, ...], count: int) -> bool:
"""An evaluation function that returns ``True`` if at least one of
*events* has been triggered."""
- pass
+ return count > 0
class AllOf(Condition):
"""A :class:`~simpy.events.Condition` event that is triggered if all of
@@ -428,4 +552,7 @@ class AnyOf(Condition):
def _describe_frame(frame: FrameType) -> str:
"""Print filename, line number and function name of a stack frame."""
- pass
\ No newline at end of file
+ filename = frame.f_code.co_filename
+ lineno = frame.f_lineno
+ funcname = frame.f_code.co_name
+ return f'{filename}:{lineno} in {funcname}'
\ No newline at end of file
diff --git a/src/simpy/resources/base.py b/src/simpy/resources/base.py
index 0904619..a6dc906 100644
--- a/src/simpy/resources/base.py
+++ b/src/simpy/resources/base.py
@@ -55,7 +55,8 @@ class Put(Event, ContextManager['Put'], Generic[ResourceType]):
method is called automatically.
"""
- pass
+ if self.resource.put_queue and self in self.resource.put_queue:
+ self.resource.put_queue.remove(self)
class Get(Event, ContextManager['Get'], Generic[ResourceType]):
"""Generic event for requesting to get something from the *resource*.
@@ -98,7 +99,8 @@ class Get(Event, ContextManager['Get'], Generic[ResourceType]):
method is called automatically.
"""
- pass
+ if self.resource.get_queue and self in self.resource.get_queue:
+ self.resource.get_queue.remove(self)
PutType = TypeVar('PutType', bound=Put)
GetType = TypeVar('GetType', bound=Get)
@@ -137,7 +139,7 @@ class BaseResource(Generic[PutType, GetType]):
@property
def capacity(self) -> Union[float, int]:
"""Maximum capacity of the resource."""
- pass
+ return self._capacity
if TYPE_CHECKING:
def put(self) -> Put:
@@ -176,7 +178,17 @@ class BaseResource(Generic[PutType, GetType]):
calls :meth:`_do_put` to check if the conditions for the event are met.
If :meth:`_do_put` returns ``False``, the iteration is stopped early.
"""
- pass
+ # Maintain queue order by iterating over a copy of the queue
+ queue = self.put_queue.copy()
+ idx = 0
+ while idx < len(queue):
+ if queue[idx] not in self.put_queue:
+ # Request has been canceled
+ idx += 1
+ continue
+ if not self._do_put(queue[idx]):
+ break
+ idx += 1
def _do_get(self, event: GetType) -> Optional[bool]:
"""Perform the *get* operation.
@@ -201,4 +213,14 @@ class BaseResource(Generic[PutType, GetType]):
calls :meth:`_do_get` to check if the conditions for the event are met.
If :meth:`_do_get` returns ``False``, the iteration is stopped early.
"""
- pass
\ No newline at end of file
+ # Maintain queue order by iterating over a copy of the queue
+ queue = self.get_queue.copy()
+ idx = 0
+ while idx < len(queue):
+ if queue[idx] not in self.get_queue:
+ # Request has been canceled
+ idx += 1
+ continue
+ if not self._do_get(queue[idx]):
+ break
+ idx += 1
\ No newline at end of file
diff --git a/src/simpy/resources/container.py b/src/simpy/resources/container.py
index 67b0cea..e8d1d0a 100644
--- a/src/simpy/resources/container.py
+++ b/src/simpy/resources/container.py
@@ -70,10 +70,24 @@ class Container(base.BaseResource):
super().__init__(env, capacity)
self._level = init
+ def _do_put(self, event: ContainerPut) -> Optional[bool]:
+ if self._level + event.amount <= self.capacity:
+ self._level += event.amount
+ event.succeed()
+ return True
+ return False
+
+ def _do_get(self, event: ContainerGet) -> Optional[bool]:
+ if self._level >= event.amount:
+ self._level -= event.amount
+ event.succeed(event.amount)
+ return True
+ return False
+
@property
def level(self) -> ContainerAmount:
"""The current amount of the matter in the container."""
- pass
+ return self._level
if TYPE_CHECKING:
def put(self, amount: ContainerAmount) -> ContainerPut:
diff --git a/src/simpy/resources/resource.py b/src/simpy/resources/resource.py
index 3f6d5a1..9ac8501 100644
--- a/src/simpy/resources/resource.py
+++ b/src/simpy/resources/resource.py
@@ -123,7 +123,10 @@ class SortedQueue(list):
Raise a :exc:`RuntimeError` if the queue is full.
"""
- pass
+ if self.maxlen is not None and len(self) >= self.maxlen:
+ raise RuntimeError('Cannot append item to full queue')
+ super().append(item)
+ self.sort(key=lambda e: e.key)
class Resource(base.BaseResource):
"""Resource with *capacity* of usage slots that can be requested by
@@ -146,10 +149,26 @@ class Resource(base.BaseResource):
self.queue = self.put_queue
'Queue of pending :class:`Request` events. Alias of\n :attr:`~simpy.resources.base.BaseResource.put_queue`.\n '
+ def _do_put(self, event: Request) -> Optional[bool]:
+ if len(self.users) < self.capacity:
+ self.users.append(event)
+ event.usage_since = self._env.now
+ event.succeed()
+ return True
+ return False
+
+ def _do_get(self, event: Release) -> Optional[bool]:
+ try:
+ self.users.remove(event.request)
+ event.succeed()
+ return True
+ except ValueError:
+ return False
+
@property
def count(self) -> int:
"""Number of users currently using the resource."""
- pass
+ return len(self.users)
if TYPE_CHECKING:
def request(self) -> Request:
diff --git a/src/simpy/resources/store.py b/src/simpy/resources/store.py
index f9e9324..010b68c 100644
--- a/src/simpy/resources/store.py
+++ b/src/simpy/resources/store.py
@@ -63,6 +63,19 @@ class Store(base.BaseResource):
super().__init__(env, capacity)
self.items: List[Any] = []
'List of the items available in the store.'
+
+ def _do_put(self, event: StorePut) -> Optional[bool]:
+ if len(self.items) < self.capacity:
+ self.items.append(event.item)
+ event.succeed()
+ return True
+ return False
+
+ def _do_get(self, event: StoreGet) -> Optional[bool]:
+ if self.items:
+ event.succeed(self.items.pop(0))
+ return True
+ return False
if TYPE_CHECKING:
def put(self, item: Any) -> StorePut:
@@ -103,6 +116,19 @@ class PriorityStore(Store):
"""
+ def _do_put(self, event: StorePut) -> Optional[bool]:
+ if len(self.items) < self.capacity:
+ heappush(self.items, event.item)
+ event.succeed()
+ return True
+ return False
+
+ def _do_get(self, event: StoreGet) -> Optional[bool]:
+ if self.items:
+ event.succeed(heappop(self.items))
+ return True
+ return False
+
class FilterStore(Store):
"""Resource with *capacity* slots for storing arbitrary objects supporting
filtered get requests. Like the :class:`Store`, the *capacity* is unlimited
@@ -124,6 +150,21 @@ class FilterStore(Store):
want it.
"""
+
+ def _do_put(self, event: StorePut) -> Optional[bool]:
+ if len(self.items) < self.capacity:
+ self.items.append(event.item)
+ event.succeed()
+ return True
+ return False
+
+ def _do_get(self, event: FilterStoreGet) -> Optional[bool]:
+ for i, item in enumerate(self.items):
+ if event.filter(item):
+ del self.items[i]
+ event.succeed(item)
+ return True
+ return False
if TYPE_CHECKING:
def get(self, filter: Callable[[Any], bool]=lambda item: True) -> FilterStoreGet:
diff --git a/src/simpy/rt.py b/src/simpy/rt.py
index 85f1da2..9564b2d 100644
--- a/src/simpy/rt.py
+++ b/src/simpy/rt.py
@@ -29,14 +29,14 @@ class RealtimeEnvironment(Environment):
@property
def factor(self) -> float:
"""Scaling factor of the real-time."""
- pass
+ return self._factor
@property
def strict(self) -> bool:
"""Running mode of the environment. :meth:`step()` will raise a
:exc:`RuntimeError` if this is set to ``True`` and the processing of
events takes too long."""
- pass
+ return self._strict
def sync(self) -> None:
"""Synchronize the internal time with the current wall-clock time.
@@ -46,7 +46,7 @@ class RealtimeEnvironment(Environment):
calling :meth:`run()` or :meth:`step()`.
"""
- pass
+ self.real_start = monotonic()
def step(self) -> None:
"""Process the next event after enough real-time has passed for the
@@ -57,4 +57,23 @@ class RealtimeEnvironment(Environment):
the event is processed too slowly.
"""
- pass
\ No newline at end of file
+ evt_time = self.peek()
+ if evt_time is Infinity:
+ raise EmptySchedule()
+
+ real_time = monotonic() - self.real_start
+ sim_time = (evt_time - self.env_start) * self.factor
+
+ if sim_time > real_time:
+ sleep(sim_time - real_time)
+ elif self.strict and sim_time < real_time:
+ # Events scheduled for time *t* may be triggered at real-time
+ # *t + ε*. For example, if an event is scheduled for t=0, it
+ # may be triggered at real-time ε, which is not a problem.
+ if real_time - sim_time > self.factor:
+ # Events scheduled for time *t* may not be triggered at
+ # real-time *t + factor + ε*, as this most likely indicates
+ # a problem with the simulation.
+ raise RuntimeError('Simulation too slow for real time (%.3fs).' % (real_time - sim_time))
+
+ return Environment.step(self)
\ No newline at end of file