--- /dev/null
+/*
+ * Copyright (c) 2023 PANTHEON.tech, s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.mdsal.binding.api;
+
+import com.google.common.annotations.Beta;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.yangtools.yang.binding.DataObject;
+
+/**
+ * Interface implemented by classes interested in receiving data changes.
+ * It provides a comparison on before-value and after-value.
+ */
+@Beta
+@FunctionalInterface
+public interface DataChangeListener<T extends DataObject> {
+ /**
+ * Invoked when there was data change for the supplied path, which was used
+ * to register the listener.
+ *
+ * <p>
+ * Note: When invoking the method {@link DataTreeChangeListener#onInitialData} initial data == null, null.
+ * @param previousValue data before.
+ * @param currentValue data after.
+ */
+ void dataChanged(@Nullable T previousValue, @Nullable T currentValue);
+}
--- /dev/null
+/*
+ * Copyright (c) 2023 PANTHEON.tech, s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.mdsal.binding.api;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ForwardingObject;
+import com.google.common.collect.Iterables;
+import java.util.Collection;
+import org.opendaylight.yangtools.yang.binding.DataObject;
+
+final class DataChangeListenerAdapter<T extends DataObject> extends ForwardingObject
+ implements ClusteredDataTreeChangeListener<T> {
+ private final DataChangeListener<T> delegate;
+
+ DataChangeListenerAdapter(final DataChangeListener<T> delegate) {
+ this.delegate = requireNonNull(delegate);
+ }
+
+ @Override
+ public void onDataTreeChanged(final Collection<DataTreeModification<T>> changes) {
+ delegate.dataChanged(changes.iterator().next().getRootNode().getDataBefore(),
+ Iterables.getLast(changes).getRootNode().getDataAfter());
+ }
+
+ @Override
+ public void onInitialData() {
+ delegate.dataChanged(null, null);
+ }
+
+ @Override
+ protected DataChangeListener<T> delegate() {
+ return delegate;
+ }
+}
--- /dev/null
+/*
+ * Copyright (c) 2023 PANTHEON.tech, s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.mdsal.binding.api;
+
+import com.google.common.annotations.Beta;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.yangtools.yang.binding.DataObject;
+
+/**
+ * Interface implemented by classes interested in receiving the last data change.
+ */
+@Beta
+@FunctionalInterface
+public interface DataListener<T extends DataObject> {
+
+ /**
+ * Invoked when there was data change for the supplied path, which was used to register listener.
+ * @param data last state.
+ */
+ void dataChangedTo(@Nullable T data);
+}
--- /dev/null
+/*
+ * Copyright (c) 2023 PANTHEON.tech, s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.mdsal.binding.api;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ForwardingObject;
+import com.google.common.collect.Iterables;
+import java.util.Collection;
+import org.opendaylight.yangtools.yang.binding.DataObject;
+
+final class DataListenerAdapter<T extends DataObject> extends ForwardingObject
+ implements ClusteredDataTreeChangeListener<T> {
+ private final DataListener<T> delegate;
+
+ DataListenerAdapter(final DataListener<T> delegate) {
+ this.delegate = requireNonNull(delegate);
+ }
+
+ @Override
+ public void onDataTreeChanged(final Collection<DataTreeModification<T>> changes) {
+ delegate.dataChangedTo(Iterables.getLast(changes).getRootNode().getDataAfter());
+ }
+
+ @Override
+ public void onInitialData() {
+ delegate.dataChangedTo(null);
+ }
+
+ @Override
+ protected DataListener<T> delegate() {
+ return delegate;
+ }
+}
import org.eclipse.jdt.annotation.NonNull;
import org.opendaylight.yangtools.concepts.ListenerRegistration;
+import org.opendaylight.yangtools.concepts.Registration;
import org.opendaylight.yangtools.yang.binding.DataObject;
+import org.opendaylight.yangtools.yang.binding.InstanceIdentifier;
/**
* A {@link BindingService} which allows users to register for changes to a subtree.
*/
<T extends DataObject, L extends DataTreeChangeListener<T>> @NonNull ListenerRegistration<L>
registerDataTreeChangeListener(@NonNull DataTreeIdentifier<T> treeId, @NonNull L listener);
+
+ /**
+ * Registers a {@link DataTreeChangeListener} to receive
+ * notifications when data changes under a given path in the conceptual data tree.
+ *
+ * <p>
+ * You are able to register for notifications for any node or subtree
+ * which can be represented using {@link DataTreeIdentifier}.
+ *
+ * <p>
+ * This method returns a {@link Registration} object. To
+ * "unregister" your listener for changes call the {@link Registration#close()}
+ * method on the returned object.
+ *
+ * <p>
+ * You MUST explicitly unregister your listener when you no longer want to receive
+ * notifications. This is especially true in OSGi environments, where failure to
+ * do so during bundle shutdown can lead to stale listeners being still registered.
+ * @implSpec This method provides {@link DataListenerAdapter} as listener during
+ * the registration of {@link DataTreeChangeListener}. This would allow users
+ * to know the last state of data instead of getting details about what changed
+ * in the entire tree.
+ * @param treeId Data tree identifier of the subtree which should be watched for
+ * changes.
+ * @param listener Listener instance which is being registered
+ * @return Listener registration object, which may be used to unregister
+ * your listener using {@link Registration#close()} to stop
+ * delivery of change events.
+ */
+ default <T extends DataObject> @NonNull Registration registerDataListener(
+ final @NonNull DataTreeIdentifier<T> treeId, final @NonNull DataListener<T> listener) {
+ return registerDataTreeChangeListener(checkNotWildcard(treeId), new DataListenerAdapter<>(listener));
+ }
+
+ /**
+ * Registers a {@link DataTreeChangeListener} to receive
+ * notifications about the last data state when it changes under a given path in the conceptual data
+ * tree.
+ *
+ * <p>
+ * You are able to register for notifications for any node or subtree
+ * which can be represented using {@link DataTreeIdentifier}.
+ *
+ * <p>
+ * This method returns a {@link Registration} object. To
+ * "unregister" your listener for changes call the {@link Registration#close()}
+ * method on the returned object.
+ *
+ * <p>
+ * You MUST explicitly unregister your listener when you no longer want to receive
+ * notifications. This is especially true in OSGi environments, where failure to
+ * do so during bundle shutdown can lead to stale listeners being still registered.
+ *
+ * @implSpec This method provides {@link DataChangeListenerAdapter} as listener during
+ * the registration of {@link DataTreeChangeListener}, which provides a comparison
+ * of before-value and after-value.
+ *
+ * @param treeId Data tree identifier of the subtree which should be watched for
+ * changes.
+ * @param listener Listener instance which is being registered
+ * @return Listener registration object, which may be used to unregister
+ * your listener using {@link Registration#close()} to stop
+ * delivery of change events.
+ */
+ default <T extends DataObject> @NonNull Registration registerDataChangeListener(
+ final @NonNull DataTreeIdentifier<T> treeId, final @NonNull DataChangeListener<T> listener) {
+ return registerDataTreeChangeListener(checkNotWildcard(treeId), new DataChangeListenerAdapter<>(listener));
+ }
+
+ private static <T extends DataObject> @NonNull DataTreeIdentifier<T> checkNotWildcard(
+ final DataTreeIdentifier<T> treeId) {
+ final @NonNull InstanceIdentifier<T> instanceIdentifier = treeId.getRootIdentifier();
+ if (instanceIdentifier.isWildcarded()) {
+ throw new IllegalArgumentException("Cannot register listener for wildcard " + instanceIdentifier);
+ }
+ return treeId;
+ }
}
\ No newline at end of file
--- /dev/null
+/*
+ * Copyright (c) 2023 PANTHEON.tech, s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.mdsal.binding.api;
+
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doCallRealMethod;
+import static org.mockito.Mockito.mock;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.opendaylight.mdsal.common.api.LogicalDatastoreType;
+import org.opendaylight.yang.gen.v1.mdsal813.norev.RegisterListenerTest;
+import org.opendaylight.yang.gen.v1.mdsal813.norev.register.listener.test.Item;
+import org.opendaylight.yangtools.yang.binding.InstanceIdentifier;
+
+public class DataTreeChangeServiceWildcardedTest {
+
+ DataBroker dataBroker;
+
+ DataListener<Item> listener;
+
+ DataChangeListener<Item> changeListener;
+
+ @Before
+ public void setUp() {
+ dataBroker = mock(DataBroker.class);
+ listener = mock(DataListener.class);
+ changeListener = mock(DataChangeListener.class);
+ }
+
+ @Test
+ public void testThrowExceptionOnRegister() {
+ final InstanceIdentifier<Item> instanceIdentifier = InstanceIdentifier.builder(RegisterListenerTest.class)
+ .child(Item.class).build();
+ final DataTreeIdentifier<Item> itemsDataTreeIdentifier = DataTreeIdentifier.create(
+ LogicalDatastoreType.OPERATIONAL, instanceIdentifier);
+
+ doCallRealMethod().when(dataBroker).registerDataListener(any(), any());
+ final var dataListenerException = assertThrows(IllegalArgumentException.class,
+ () -> dataBroker.registerDataListener(itemsDataTreeIdentifier, listener));
+ assertTrue(dataListenerException.getMessage().contains("Cannot register listener for wildcard"));
+
+ doCallRealMethod().when(dataBroker).registerDataChangeListener(any(), any());
+ final var dataListenerChangeException = assertThrows(IllegalArgumentException.class,
+ () -> dataBroker.registerDataChangeListener(itemsDataTreeIdentifier, changeListener));
+ assertTrue(dataListenerChangeException.getMessage().contains("Cannot register listener for wildcard"));
+ }
+
+}
--- /dev/null
+/*
+ * Copyright (c) 2023 PANTHEON.tech, s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.mdsal.binding.dom.adapter;
+
+import static java.util.Objects.requireNonNull;
+
+import java.util.List;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.mdsal.binding.api.DataChangeListener;
+import org.opendaylight.mdsal.binding.dom.codec.api.BindingAugmentationCodecTreeNode;
+import org.opendaylight.mdsal.binding.dom.codec.api.BindingDataObjectCodecTreeNode;
+import org.opendaylight.mdsal.binding.dom.codec.api.CommonDataObjectCodecTreeNode;
+import org.opendaylight.mdsal.dom.api.ClusteredDOMDataTreeChangeListener;
+import org.opendaylight.yangtools.yang.binding.DataObject;
+import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode;
+import org.opendaylight.yangtools.yang.data.tree.api.DataTreeCandidate;
+
+final class BindingDOMDataChangeListenerAdapter<T extends DataObject> implements ClusteredDOMDataTreeChangeListener {
+ private final AdapterContext adapterContext;
+ private final DataChangeListener<T> listener;
+
+ BindingDOMDataChangeListenerAdapter(final AdapterContext adapterContext, final DataChangeListener<T> listener) {
+ this.adapterContext = requireNonNull(adapterContext);
+ this.listener = requireNonNull(listener);
+ }
+
+ @Override
+ public void onDataTreeChanged(final List<DataTreeCandidate> changes) {
+ final var first = changes.get(0);
+ final var serializer = adapterContext.currentSerializer();
+ final var codec = serializer.getSubtreeCodec(serializer.coerceInstanceIdentifier(first.getRootPath()));
+
+ listener.dataChanged(deserialize(codec, first.getRootNode().dataBefore()),
+ deserialize(codec, changes.get(changes.size() - 1).getRootNode().dataAfter()));
+ }
+
+ @Override
+ public void onInitialData() {
+ listener.dataChanged(null, null);
+ }
+
+ @SuppressWarnings("unchecked")
+ private @Nullable T deserialize(final CommonDataObjectCodecTreeNode<?> codec, final NormalizedNode data) {
+ if (data == null) {
+ return null;
+ } else if (codec instanceof BindingDataObjectCodecTreeNode<?> dataObject) {
+ return (T) dataObject.deserialize(data);
+ } else if (codec instanceof BindingAugmentationCodecTreeNode<?> augmentation) {
+ return (T) augmentation.filterFrom(data);
+ } else {
+ throw new IllegalStateException("Unhandled codec " + codec);
+ }
+ }
+}
--- /dev/null
+/*
+ * Copyright (c) 2023 PANTHEON.tech, s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.mdsal.binding.dom.adapter;
+
+import static java.util.Objects.requireNonNull;
+
+import java.util.List;
+import org.eclipse.jdt.annotation.NonNull;
+import org.opendaylight.mdsal.binding.api.DataListener;
+import org.opendaylight.mdsal.binding.dom.codec.api.BindingAugmentationCodecTreeNode;
+import org.opendaylight.mdsal.binding.dom.codec.api.BindingDataObjectCodecTreeNode;
+import org.opendaylight.mdsal.dom.api.ClusteredDOMDataTreeChangeListener;
+import org.opendaylight.yangtools.yang.binding.DataObject;
+import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
+import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode;
+import org.opendaylight.yangtools.yang.data.tree.api.DataTreeCandidate;
+
+final class BindingDOMDataListenerAdapter<T extends DataObject> implements ClusteredDOMDataTreeChangeListener {
+ private final AdapterContext adapterContext;
+ private final DataListener<T> listener;
+
+ BindingDOMDataListenerAdapter(final AdapterContext adapterContext, final DataListener<T> listener) {
+ this.adapterContext = requireNonNull(adapterContext);
+ this.listener = requireNonNull(listener);
+ }
+
+ @Override
+ public void onDataTreeChanged(final List<DataTreeCandidate> changes) {
+ final var last = changes.get(changes.size() - 1);
+ final var after = last.getRootNode().dataAfter();
+ listener.dataChangedTo(after == null ? null : deserialize(last.getRootPath(), after));
+ }
+
+ @Override
+ public void onInitialData() {
+ listener.dataChangedTo(null);
+ }
+
+ @SuppressWarnings("unchecked")
+ private T deserialize(final YangInstanceIdentifier path, final @NonNull NormalizedNode data) {
+ final var serializer = adapterContext.currentSerializer();
+ final var codec = serializer.getSubtreeCodec(serializer.coerceInstanceIdentifier(path));
+ if (codec instanceof BindingDataObjectCodecTreeNode<?> dataObject) {
+ return (T) dataObject.deserialize(data);
+ } else if (codec instanceof BindingAugmentationCodecTreeNode<?> augmentation) {
+ return (T) augmentation.filterFrom(data);
+ } else {
+ throw new IllegalStateException("Unhandled codec " + codec);
+ }
+ }
+}
*/
package org.opendaylight.mdsal.binding.dom.adapter;
+import org.eclipse.jdt.annotation.NonNull;
import org.opendaylight.mdsal.binding.api.ClusteredDataTreeChangeListener;
+import org.opendaylight.mdsal.binding.api.DataChangeListener;
+import org.opendaylight.mdsal.binding.api.DataListener;
import org.opendaylight.mdsal.binding.api.DataTreeChangeListener;
import org.opendaylight.mdsal.binding.api.DataTreeChangeService;
import org.opendaylight.mdsal.binding.api.DataTreeIdentifier;
import org.opendaylight.mdsal.dom.api.DOMDataTreeChangeService;
import org.opendaylight.mdsal.dom.api.DOMDataTreeIdentifier;
import org.opendaylight.yangtools.concepts.ListenerRegistration;
+import org.opendaylight.yangtools.concepts.Registration;
import org.opendaylight.yangtools.yang.binding.Augmentation;
import org.opendaylight.yangtools.yang.binding.DataObject;
return new BindingDataTreeChangeListenerRegistration<>(listener, domReg);
}
- private DOMDataTreeIdentifier toDomTreeIdentifier(final DataTreeIdentifier<?> treeId) {
+ @Override
+ public <T extends DataObject> Registration registerDataListener(final DataTreeIdentifier<T> treeId,
+ final DataListener<T> listener) {
+ return getDelegate().registerDataTreeChangeListener(toDomTreeInstance(treeId),
+ new BindingDOMDataListenerAdapter<>(adapterContext(), listener));
+ }
+
+ @Override
+ public <T extends DataObject> Registration registerDataChangeListener(final DataTreeIdentifier<T> treeId,
+ final DataChangeListener<T> listener) {
+ return getDelegate().registerDataTreeChangeListener(toDomTreeInstance(treeId),
+ new BindingDOMDataChangeListenerAdapter<>(adapterContext(), listener));
+ }
+
+ private @NonNull DOMDataTreeIdentifier toDomTreeIdentifier(final DataTreeIdentifier<?> treeId) {
return new DOMDataTreeIdentifier(treeId.getDatastoreType(),
currentSerializer().toYangInstanceIdentifier(treeId.getRootIdentifier()));
}
+
+ private @NonNull DOMDataTreeIdentifier toDomTreeInstance(final DataTreeIdentifier<?> treeId) {
+ final var instanceIdentifier = treeId.getRootIdentifier();
+ if (instanceIdentifier.isWildcarded()) {
+ throw new IllegalArgumentException("Cannot register listener for wildcard " + instanceIdentifier);
+ }
+ return toDomTreeIdentifier(treeId);
+ }
}
--- /dev/null
+/*
+ * Copyright (c) 2023 PANTHEON.tech, s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.mdsal.binding.dom.adapter;
+
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.opendaylight.mdsal.binding.api.DataBroker;
+import org.opendaylight.mdsal.binding.api.DataChangeListener;
+import org.opendaylight.mdsal.binding.api.DataListener;
+import org.opendaylight.mdsal.binding.api.DataTreeIdentifier;
+import org.opendaylight.mdsal.binding.api.WriteTransaction;
+import org.opendaylight.mdsal.binding.dom.adapter.test.AbstractDataBrokerTest;
+import org.opendaylight.mdsal.common.api.LogicalDatastoreType;
+import org.opendaylight.yang.gen.v1.mdsal813.norev.RegisterListenerTest;
+import org.opendaylight.yang.gen.v1.mdsal813.norev.RegisterListenerTestBuilder;
+import org.opendaylight.yang.gen.v1.mdsal813.norev.register.listener.test.Item;
+import org.opendaylight.yang.gen.v1.mdsal813.norev.register.listener.test.ItemBuilder;
+import org.opendaylight.yang.gen.v1.mdsal813.norev.register.listener.test.ItemKey;
+import org.opendaylight.yangtools.yang.binding.InstanceIdentifier;
+import org.opendaylight.yangtools.yang.binding.util.BindingMap;
+import org.opendaylight.yangtools.yang.common.Uint32;
+
+public class DataListenerTest extends AbstractDataBrokerTest {
+ DataBroker dataBroker;
+
+ DataListener<Item> listener;
+
+ DataChangeListener<Item> changeListener;
+
+ @Before
+ public void setUp() {
+ dataBroker = getDataBroker();
+ listener = mock(DataListener.class);
+ changeListener = mock(DataChangeListener.class);
+ }
+
+ @Test
+ public void testThrowExceptionOnRegister() {
+ final InstanceIdentifier<Item> instanceIdentifier = InstanceIdentifier.builder(RegisterListenerTest.class)
+ .child(Item.class).build();
+ final DataTreeIdentifier<Item> itemsDataTreeIdentifier = DataTreeIdentifier.create(
+ LogicalDatastoreType.OPERATIONAL,
+ instanceIdentifier);
+
+ final Throwable dataListenerException = assertThrows(IllegalArgumentException.class,
+ () -> dataBroker.registerDataListener(itemsDataTreeIdentifier, listener));
+ assertTrue(dataListenerException.getMessage().contains("Cannot register listener for wildcard"));
+
+ final Throwable dataListenerChangeException = assertThrows(IllegalArgumentException.class,
+ () -> dataBroker.registerDataChangeListener(itemsDataTreeIdentifier, changeListener));
+ assertTrue(dataListenerChangeException.getMessage().contains("Cannot register listener for wildcard"));
+ }
+
+ @Test
+ public void testRegisterDataListener() {
+ final Item item = writeItems();
+ final InstanceIdentifier<Item> instanceIdentifier = InstanceIdentifier.builder(RegisterListenerTest.class)
+ .child(Item.class, new ItemKey(item.key())).build();
+
+ dataBroker.registerDataListener(
+ DataTreeIdentifier.create(LogicalDatastoreType.CONFIGURATION, instanceIdentifier), listener);
+
+ verify(listener, timeout(100)).dataChangedTo(item);
+ }
+
+ @Test
+ public void testRegisterDataChangeListener() {
+ final Item item = writeItems();
+ final InstanceIdentifier<Item> instanceIdentifier = InstanceIdentifier.builder(RegisterListenerTest.class)
+ .child(Item.class, new ItemKey(item.key())).build();
+
+ dataBroker.registerDataChangeListener(
+ DataTreeIdentifier.create(LogicalDatastoreType.CONFIGURATION, instanceIdentifier), changeListener);
+
+ verify(changeListener, timeout(100)).dataChanged(null, item);
+ }
+
+ private Item writeItems() {
+ final WriteTransaction writeTransaction = getDataBroker().newWriteOnlyTransaction();
+ final Item wildcardItem = new ItemBuilder().setText("name").setNumber(Uint32.valueOf(43)).build();
+ final RegisterListenerTestBuilder builder = new RegisterListenerTestBuilder().setItem(
+ BindingMap.of(wildcardItem));
+ writeTransaction.put(LogicalDatastoreType.CONFIGURATION, InstanceIdentifier.builder(
+ RegisterListenerTest.class).build(), builder.build());
+ assertCommit(writeTransaction.commit());
+ return wildcardItem;
+ }
+}
--- /dev/null
+module mdsal813 {
+ namespace mdsal813;
+ prefix mdsal813;
+
+ container register-listener-test {
+ list item {
+ key "text number";
+ leaf text {
+
+ type string;
+ }
+ leaf number {
+ type uint32;
+ }
+ }
+ }
+}
\ No newline at end of file