Skip to content

Commit

Permalink
Initial commit.
Browse files Browse the repository at this point in the history
Signed-off-by: Benjamin P. Jung <[email protected]>
  • Loading branch information
headcr4sh committed Jul 10, 2024
0 parents commit 79d337d
Show file tree
Hide file tree
Showing 10 changed files with 368 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
root = true

[*]
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/.dart_tool/
/.vscode/settings.json

/pubspec.lock

/build/
/doc/
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.0] - 2024-07-10

### Added

- Initial release.

[1.0.0]: https://github.com/cathive/dart-file-tailer/releases/tag/v1.0.0
27 changes: 27 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Copyright 2014, Benjamin Patrick Jung.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# file_tailer

A cross-platform file tailing library for Dart.

The file_tailer package provides functionality to read the stream the contents of a file to which data might be appended. This is especially useful for log files.

## Using

The file_tailer library was designed to be used without a prefix.

```dart
import 'package:file_tailer/file_tailer.dart';
```

The most common way to use this library to stream log files is by handling the data that is emitted
by tailing a file:

```dart
import 'dart:convert' show LineSplitter, utf8;
import 'dart:io' show File, stderr, stdout;
import 'package:file_tailer/file_tailer.dart' show tailFile;
void main(List<String> arguments) {
if (arguments.length != 1) {
stderr.write('You need to provide exactly one file to be tailed.\n');
return 1;
}
final (stream, _) = tailFile(File(arguments.first));
stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.forEach((line) async => stdout.write('$line\n'));
}
```
17 changes: 17 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
include: package:dart_flutter_team_lints/analysis_options.yaml

linter:
rules:
- avoid_private_typedef_functions
- avoid_unused_constructor_parameters
- avoid_void_async
- cancel_subscriptions
- join_return_with_assignment
- missing_whitespace_between_adjacent_strings
- no_runtimeType_toString
- package_api_docs
- prefer_const_declarations
- prefer_expression_function_bodies
- prefer_final_locals
- unnecessary_breaks
- use_string_buffers
16 changes: 16 additions & 0 deletions example/example.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import 'dart:convert' show LineSplitter, utf8;
import 'dart:io' show File, stderr, stdout;

import 'package:file_tailer/file_tailer.dart' show tailFile;

void main(List<String> arguments) {
if (arguments.length != 1) {
stderr.write('You need to provide exactly one file to be tailed.\n');
return 1;
}
final (stream, _) = tailFile(File(arguments.first));
stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.forEach((line) async => stdout.write('$line\n'));
}
126 changes: 126 additions & 0 deletions lib/file_tailer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import 'dart:async' show Completer;
import 'dart:io'
show
File,
FileMode,
FileSystemEvent,
FileSystemModifyEvent,
RandomAccessFile;
import 'dart:typed_data' show Uint8List;

import 'package:async/async.dart' show StreamGroup;

const _DEFAULT_BUFFER_SIZE = 8192;
const _DEFAULT_READ_TIMEOUT = Duration(milliseconds: 100);

/// Facility to tail the contents of a file.
abstract class FileTailer {
File get file;

/// Starts to read from the file pointed at by this tailer.
/// The caller of this function is responsible for cancelling the
/// read request by calling the appropriate method if no more data
/// shall be received.
Stream<List<int>> stream();

/// Cancels the tailing once the given position at [pos] has been reached.
/// Pass `-1` as [pos] to cancel immediately.
Future<void> cancel({int pos = -1});

// Creates a new tailer, that can be used to stream the contents of a file.
factory FileTailer(final File file,
{final int bufferSize = _DEFAULT_BUFFER_SIZE,
final Duration readTimeout = _DEFAULT_READ_TIMEOUT}) =>
_FileTailer(file, bufferSize: bufferSize, readTimeout: readTimeout);
}

// Starts tailing the contents of a file.
(Stream<List<int>>, Future<void> Function({int pos})) tailFile(final File file,
{final int bufferSize = _DEFAULT_BUFFER_SIZE,
final Duration readTimeout = _DEFAULT_READ_TIMEOUT}) {
final tailer =
FileTailer(file, bufferSize: bufferSize, readTimeout: readTimeout);
return (tailer.stream(), tailer.cancel);
}

/// Default implementation of the file tailer interface.
class _FileTailer implements FileTailer {
final File _file;
final Uint8List _buf;
final Duration _readTimeout;

final Completer<void> _done = Completer();

int _pos;
bool _cancelled = false;
int _cancelledPos = -1;

_FileTailer(final File file,
{required final int bufferSize, required final Duration readTimeout})
: _file = file,
_buf = Uint8List(bufferSize),
_readTimeout = readTimeout,
_pos = 0;

@override
File get file => _file;

@override
Stream<List<int>> stream() async* {
final events = StreamGroup.merge([
// Initial event, because the file might already contain data which
// we want to consume before something gets appended.
Stream.value(FileSystemModifyEvent(_file.path, false, true)),
// Modification events
_file.watch(events: FileSystemEvent.all)
]);

final fileHandle = await _file.open(mode: FileMode.read);
_pos = await fileHandle.position();

// Wait for modify events and read more bytes from file
await for (final event in events) {
if (_cancelled) {
await fileHandle.close();
return;
}
switch (event.type) {
case FileSystemEvent.modify:
yield* _read(fileHandle);
break;
case FileSystemEvent.delete:
await cancel();
break;
default:
// All other events should be ignored for now.
break;
}
if (_cancelled) {
await fileHandle.close();
return;
}
}
}

@override
Future<void> cancel({final int pos = -1}) async {
_cancelled = true;
_cancelledPos = pos;

// Wait until reading has truly come to an end.
return _done.future;
}

Stream<Uint8List> _read(final RandomAccessFile fileHandle) async* {
while (!(_cancelled && _pos >= _cancelledPos)) {
final bytesRead = await fileHandle.readInto(_buf).timeout(_readTimeout);
if (bytesRead == 0) {
// Let's check if we have been cancelled.
continue;
}
_pos += bytesRead;
yield _buf.sublist(0, bytesRead);
}
_done.complete();
}
}
19 changes: 19 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: file_tailer
description: Watch for file changes

version: "1.0.0"

repository: https://github.com/cathive/dart-file-tailer

environment:
sdk: ">=3.0.0 <4.0.0"

dependencies:
async: ^2.11.0

dev_dependencies:
dart_flutter_team_lints: ^3.1.0
file: ^7.0.0
path: ^1.9.0
test: ^1.25.8

101 changes: 101 additions & 0 deletions test/file_tailer_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import 'dart:convert' show LineSplitter, utf8;
import 'dart:io' show Directory, File, FileMode, IOSink;

import 'package:file/memory.dart' show FileSystemStyle, MemoryFileSystem;
import 'package:file_tailer/file_tailer.dart' show FileTailer;
import 'package:path/path.dart' as path;
import 'package:test/test.dart' show group, setUp, tearDown, test, expect;

Directory? tmpDir;

/// Test data
List<String> movies = [
'Star Wars Episode IV - A New Hope',
'Star Wars Episode V - The Empire Strikes Back',
'Star Wars Episode VI - Return of the Jedi',
'Star Wars Episode VII - The Force Awakens'
];

typedef AsyncCallback = Future<void> Function();

class FileContentsTester {
final File _file;
final List<String> _lines;
final AsyncCallback? _onClose;
IOSink? _ioSink;
int _linesWritten = 0;
FileContentsTester(final File file, final List<String> lines,
{final AsyncCallback? onClose})
: _file = file,
_lines = lines,
_onClose = onClose {
_ioSink = _file.openWrite(mode: FileMode.writeOnlyAppend);
}

IOSink get ioSink => _ioSink!;

bool get hasNext => _linesWritten < _lines.length;

Future<void> writeNext() async {
if (hasNext) {
ioSink.writeln(_lines[_linesWritten]);
await ioSink.flush();
_linesWritten++;
} else {
throw StateError('Cannot write data past last line.');
}
}

Future<void> writeAll() async {
while (hasNext) {
await writeNext();
}

await ioSink.flush();
await ioSink.close();
if (_onClose != null) {
await _onClose!();
}
}
}

void main() {
setUp(() async {
tmpDir = await Directory.systemTemp.createTemp('file_tailer_test_');
});
tearDown(() async {
if (await tmpDir!.exists()) {
await tmpDir!.delete(recursive: true);
}
});
group('FileTailer', () {
final fs = MemoryFileSystem(style: FileSystemStyle.posix);
test('Default constructor / factory', () {
final file = fs.file('/tmp/does-not-exist.txt');
final tailer = FileTailer(file);
expect(tailer.file, file);
});
test('tail()', () async {
final file = await File(path.join(tmpDir!.path, 'movies.txt')).create();
final tailer = FileTailer(file);
final tester = FileContentsTester(file, movies,
onClose: () async => await tailer.cancel(pos: await file.length()));

var idx = 0;

await Future.wait([
tester.writeAll(),
tailer
.stream()
.transform(utf8.decoder)
.transform(LineSplitter())
.forEach((line) async {
expect(line, movies[idx]);
idx++;
})
]);

expect(movies.length, idx);
});
});
}

0 comments on commit 79d337d

Please sign in to comment.