Add Text.write Function (#3518)

Implements https://www.pivotaltracker.com/story/show/182309026
This commit is contained in:
Radosław Waśko 2022-06-13 11:11:46 +02:00 committed by GitHub
parent a1bf0974ce
commit a04825a5ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 256 additions and 84 deletions

View File

@ -138,6 +138,7 @@
- [Renamed `File_Format.Text` to `Plain_Text`, updated `File_Format.Delimited`
API and added builders for customizing less common settings.][3516]
- [Allow control of sort direction in `First` and `Last` aggregations.][3517]
- [Implemented `Text.write`, replacing `File.write_text`.][3518]
[debug-shortcuts]:
https://github.com/enso-org/enso/blob/develop/app/gui/docs/product/shortcuts.md#debug
@ -217,6 +218,7 @@
[3515]: https://github.com/enso-org/enso/pull/3515
[3516]: https://github.com/enso-org/enso/pull/3516
[3517]: https://github.com/enso-org/enso/pull/3517
[3518]: https://github.com/enso-org/enso/pull/3518
#### Enso Compiler

View File

@ -53,4 +53,4 @@ component-groups:
- Standard.Base.Data.Vector.Vector.distinct
- Output:
exports:
- Standard.Base.System.File.File.write_text
- Standard.Base.Data.Text.Text.write

View File

@ -576,4 +576,4 @@ Unimplemented_Error.to_display_text = "An implementation is missing: " + this.me
example_unimplemented = Errors.unimplemented
unimplemented : Text -> Void
unimplemented message="" = Panic.throw (Unimplemented_Error message)
unimplemented message="" = Panic.throw (Unimplemented_Error message)

View File

@ -1,6 +1,7 @@
from Standard.Base import all
import Standard.Base.System.File.Option
import Standard.Base.System.File.Existing_File_Behavior
import Standard.Base.Data.Text.Matching_Mode
import Standard.Base.Data.Text.Text_Sub_Range
from Standard.Base.Data.Text.Encoding as Encoding_Module import Encoding
@ -13,8 +14,10 @@ polyglot java import java.io.InputStream as Java_Input_Stream
polyglot java import java.io.IOException
polyglot java import java.nio.file.AccessDeniedException
polyglot java import java.nio.file.NoSuchFileException
polyglot java import java.nio.file.FileAlreadyExistsException
polyglot java import java.nio.file.FileSystems
polyglot java import java.nio.file.Path
polyglot java import java.nio.file.StandardCopyOption
## ALIAS New File
@ -89,36 +92,6 @@ read_text path (encoding=Encoding.utf_8) (on_problems=Report_Warning) =
_ -> Error.throw (Illegal_Argument_Error "path should be either a File or a Text")
file.read_text encoding on_problems
## ALIAS Write Text File
Open and write to the file at the provided `path`.
Arguments:
- path: The path of the file to open and read the contents of. It will
accept a textual path or a file.
- contents: The text to write to the file.
- encoding: The text encoding to decode the file with. Defaults to UTF-8.
? Module or Instance?
If you have a variable `file` of type `File`, we recommend calling the
`.read_text` method on it directly, rather than using
`File.read_text file`. The later, however, will still work.
> Example
Read the `data.csv` file from the Examples project.
import Standard.Base.System.File
import Standard.Examples
example_read = File.read_text Examples.csv_path
write_text : (Text | File) -> Text -> Encoding -> Text
write_text path contents (encoding=Encoding.utf_8) =
file = case path of
Text -> (here.new path)
File _ -> path
_ -> Error.throw (Illegal_Argument_Error "path should be either a File or a Text")
file.write_text contents encoding
## ALIAS Current Directory
Returns the current working directory (CWD) of the current program.
@ -311,20 +284,6 @@ type File
opts = [Option.Append, Option.Create]
this.with_output_stream opts (_.write_bytes contents)
## Appends a UTF-8 encoded `Text` at the end of this file.
Arguments:
- contents: The UTF-8 encoded text to append to the file.
> Example
Append the text "hello" to a file.
import Standard.Examples
example_append = Examples.scratch_file.append "hello"
append : Text -> Nothing ! File_Error
append contents = this.append_bytes contents.utf_8
## Writes a number of bytes into this file, replacing any existing contents.
Arguments:
@ -344,27 +303,6 @@ type File
this.with_output_stream opts (_.write_bytes contents)
Nothing
## ALIAS Write Text File
Writes a `Text` into this file with specified encoding, replacing any
existing contents.
Arguments:
- contents: The text to write to the file.
- encoding: The text encoding to decode the file with. Defaults to UTF-8.
If the file does not exist, it will be created.
> Example
Write the text "hello" to a file.
import Standard.Examples
example_write = Examples.scratch_file.write "hello"
write_text : Text -> Encoding -> Nothing ! File_Error
write_text contents (encoding=Encoding.utf_8) =
this.write_bytes <| contents.bytes encoding
## Join two path segments together.
Arguments:
@ -561,12 +499,24 @@ type File
example_delete =
file = Examples.data_dir / "my_file"
file.write_text "hello"
"hello".write file
file.delete
delete : Nothing ! File_Error
delete =
here.handle_java_exceptions this this.prim_file.delete
## Moves the file to the specified destination.
Arguments:
- destination: the destination to move the file to.
- replace_existing: specifies if the operation should proceed if the
destination file already exists. Defaults to `True`.
move_to : File -> Boolean -> Nothing ! File_Error
move_to destination replace_existing=True =
here.handle_java_exceptions this <| case replace_existing of
True -> this.prim_file.move destination.prim_file StandardCopyOption.REPLACE_EXISTING
False -> this.prim_file.move destination.prim_file
## Deletes the file if it exists on disk.
If the file is a directory, it must be empty, otherwise a `Panic` will
@ -891,8 +841,9 @@ handle_java_exceptions file ~action =
Converts a Java `IOException` into its Enso counterpart.
wrap_io_exception file io_exception =
if Java.is_instance io_exception NoSuchFileException then Error.throw (File_Not_Found file) else
if Java.is_instance io_exception AccessDeniedException then Error.throw (Io_Error file "You do not have permission to access the file") else
Error.throw (Io_Error file "An IO error has occurred: "+io_exception.getMessage)
if Java.is_instance io_exception FileAlreadyExistsException then Error.throw (File_Already_Exists_Error file) else
if Java.is_instance io_exception AccessDeniedException then Error.throw (Io_Error file "You do not have permission to access the file") else
Error.throw (Io_Error file "An IO error has occurred: "+io_exception.getMessage)
## PRIVATE
@ -911,6 +862,9 @@ type File_Error
- file: The file that doesn't exist.
type File_Not_Found file
## Indicates that a destination file already exists.
type File_Already_Exists_Error file
## A generic IO error.
Arguments:
@ -925,6 +879,7 @@ type File_Error
to_display_text = case this of
File_Not_Found file -> "The file at " + file.path + " does not exist."
Io_Error file msg -> msg.to_text + " (" + file.path + ")."
File_Already_Exists_Error file -> "The file at "+file.path+" already exists."
## PRIVATE
@ -975,3 +930,29 @@ get_file path = @Builtin_Method "File.get_file"
Gets the textual path to the user's system-defined home directory.
user_home : Text
user_home = @Builtin_Method "File.user_home"
## Writes (or appends) the specified bytes to the specified file.
The behavior specified in the `existing_file` parameter will be used if the
file exists.
Arguments:
- path: The path to the target file.
- encoding: The encoding to use when writing the file.
- on_existing_file: Specifies how to proceed if the file already exists.
- on_problems: Specifies how to handle any encountered problems.
If a character cannot be converted to a byte, an `Encoding_Error` is raised.
If `on_problems` is set to `Report_Warning` or `Ignore`, it is replaced with
a substitute (either <20> (if Unicode) or ? depending on the encoding).
Otherwise, the process is aborted.
If the path to the parent location cannot be found or the filename is
invalid, a `File_Not_Found` is raised.
If another error occurs, such as access denied, an `Io_Error` is raised.
Otherwise, the file is created with the encoded text written to it.
Text.write : (File|Text) -> Encoding -> Existing_File_Behavior -> Problem_Behavior -> Nothing ! Encoding_Error | Illegal_Argument_Error | File_Not_Found | Io_Error | File_Already_Exists_Error
Text.write path encoding=Encoding.utf_8 on_existing_file=Existing_File_Behavior.Backup on_problems=Report_Warning =
bytes = this.bytes encoding on_problems
file = here.new path
on_existing_file.write file stream->
stream.write_bytes bytes

View File

@ -0,0 +1,89 @@
from Standard.Base import all
import Standard.Base.System.File.Option
from Standard.Base.System.File import File_Already_Exists_Error, Io_Error, File_Not_Found
## Specifies the behavior of a write operation when the destination file
already exists.
type Existing_File_Behavior
## Replace the existing file in-place, with the new file.
Note: There is a risk of data loss if a failure occurs during the write
operation.
type Overwrite
## Creates a backup of the existing file (by appending a `.bak` suffix to
the name) before replacing it with the new contents.
Note: This requires sufficient storage to have two copies of the file.
If an existing `.bak` file exists, it will be replaced.
type Backup
## Appends data to the existing file.
type Append
## If the file already exists, a `File_Already_Exists_Error` error is
raised.
type Error
## PRIVATE
Runs the `action` which is given a file output stream and should write
the required contents to it.
The handle is configured depending on the specified behavior, it may
point to a temporary file, for example. The stream may only be used while
the action is being executed and it should not be stored anywhere for
later.
The `action` may not be run at all in case the `Error` behavior is
selected.
write : File -> (Output_Stream -> Nothing) -> Nothing ! File_Not_Found | Io_Error | File_Already_Exists_Error
write file action =
case this of
Overwrite -> file.with_output_stream [Option.Write, Option.Create, Option.Truncate_Existing] action
Append -> file.with_output_stream [Option.Write, Option.Create, Option.Append] action
Error -> file.with_output_stream [Option.Write, Option.Create_New] action
Backup -> Panic.recover [Io_Error, File_Not_Found] <|
handle_existing_file _ =
here.write_file_backing_up_old_one file action
## We first attempt to write the file to the original
destination, but if that files due to the file already
existing, we will run the alternative algorithm which uses a
temporary file and creates a backup.
Panic.catch File_Already_Exists_Error handler=handle_existing_file <|
Panic.rethrow <| file.with_output_stream [Option.Write, Option.Create_New] action
## PRIVATE
write_file_backing_up_old_one : File -> (Output_Stream -> Nothing) -> Nothing ! File_Not_Found | Io_Error | File_Already_Exists_Error
write_file_backing_up_old_one file action = Panic.recover [Io_Error, File_Not_Found] <|
parent = file.parent
bak_file = parent / file.name+".bak"
go i =
new_name = file.name + ".new" + if i == 0 then "" else "." + i.to_text
new_file = parent / new_name
handle_existing_file _ = go i+1
handle_write_failure panic =
## Since we were already inside of the write operation,
the file must have been created, but since we failed, we need to clean it up.
new_file.delete
Panic.throw panic.payload.cause
Panic.catch File_Already_Exists_Error handler=handle_existing_file <|
Panic.catch Internal_Write_Operation_Failed handler=handle_write_failure <|
Panic.rethrow <|
new_file.with_output_stream [Option.Write, Option.Create_New] output_stream->
Panic.catch Any (action output_stream) caught_panic->
Panic.throw (Internal_Write_Operation_Failed caught_panic)
## We ignore the file not found error, because it means that there
is no file to back-up. This may also be caused by someone
removing the original file during the time when we have been
writing the new one to the temporary location. There is nothing
to back-up anymore, but this is not a failure, so it can be
safely ignored.
Panic.catch File_Not_Found handler=(_->Nothing) <|
Panic.rethrow <| file.move_to bak_file
Panic.rethrow <| new_file.move_to file
go 0
## PRIVATE
type Internal_Write_Operation_Failed (cause : Caught_Panic)

View File

@ -1306,7 +1306,7 @@ type Table
example_to_csv = Examples.inventory_table.write_csv (Enso_Project.data / 'example.json')
write_json : File.File -> Nothing
write_json file = file.write_text this.to_json.to_text
write_json file = this.to_json.to_text.write file
## UNSTABLE

View File

@ -712,7 +712,7 @@ wrap_junit_testsuites config builder ~action =
if config.should_output_junit then
builder.append '</testsuites>\n'
config.output_path.parent.create_directory
config.output_path.write_text builder.toString
builder.toString.write config.output_path
result

View File

@ -4,6 +4,7 @@ import com.oracle.truffle.api.TruffleFile;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.CopyOption;
import java.nio.file.OpenOption;
/**
@ -89,6 +90,10 @@ public class EnsoFile {
truffleFile.delete();
}
public void move(EnsoFile target, CopyOption... options) throws IOException {
truffleFile.move(target.truffleFile, options);
}
public boolean startsWith(EnsoFile parent) {
return truffleFile.startsWith(parent.truffleFile);
}

View File

@ -1 +1 @@
*.csv
*.csv*

View File

@ -109,7 +109,7 @@ spec =
create_file name ending_style =
lines = ['a,b,c', 'd,e,f', '1,2,3']
text = lines.join ending_style
(path name).write_text text Encoding.utf_8
text.write (path name)
test_file name =
table = File.read (path name) (Delimited "," headers=True value_formatter=Nothing) Problem_Behavior.Report_Error
@ -126,7 +126,7 @@ spec =
test_file 'cr.csv'
# Currently mixed line endings are not supported.
(path 'mixed.csv').write_text 'a,b,c\nd,e,f\r1,2,3'
'a,b,c\nd,e,f\r1,2,3'.write (path 'mixed.csv')
File.read (path 'mixed.csv') (Delimited "," headers=True value_formatter=Nothing) Problem_Behavior.Report_Error . should_fail_with Invalid_Row
Test.specify "should work with Windows-1252 encoding" <|

View File

@ -1 +1 @@
*.txt
*.txt*

View File

@ -1,6 +1,8 @@
from Standard.Base import all
from Standard.Base.Data.Text.Encoding as Encoding_Module import Encoding, Encoding_Error
import Standard.Base.System.File.Existing_File_Behavior
from Standard.Base.System.File import File_Already_Exists_Error
import Standard.Test
import Standard.Test.Problems
@ -46,7 +48,7 @@ spec =
f = Enso_Project.data / "short.txt"
f.delete_if_exists
f.exists.should_be_false
f.write_text "Cup"
"Cup".write f on_existing_file=Existing_File_Behavior.Overwrite
f.with_input_stream stream->
stream.read_byte.should_equal 67
stream.read_byte.should_equal 117
@ -118,18 +120,111 @@ spec =
contents_2.should .start_with "Cupcake ipsum dolor sit amet."
Test.group "write operations" <|
Test.specify "should write and append to files" <|
f = Enso_Project.data / "work.txt"
transient = Enso_Project.data / "transient"
Test.specify "should allow to append to files" <|
f = transient / "work.txt"
f.delete_if_exists
f.exists.should_be_false
f.write_text "line 1!"
"line 1!".write f on_existing_file=Existing_File_Behavior.Append
f.exists.should_be_true
f.read_text.should_equal "line 1!"
f.append '\nline 2!'
'\nline 2!'.write f on_existing_file=Existing_File_Behavior.Append
f.read_text.should_equal 'line 1!\nline 2!'
f.delete
f.exists.should_be_false
Test.specify "should allow to overwrite files" <|
f = transient / "work.txt"
f.delete_if_exists
f.exists.should_be_false
"line 1!".write f on_existing_file=Existing_File_Behavior.Overwrite . should_equal Nothing
f.exists.should_be_true
f.read_text.should_equal "line 1!"
'line 2!'.write f on_existing_file=Existing_File_Behavior.Overwrite . should_equal Nothing
f.read_text.should_equal 'line 2!'
f.delete
f.exists.should_be_false
Test.specify "should fail if a file already exists, depending on the settings" <|
f = transient / "work.txt"
f.delete_if_exists
f.exists.should_be_false
"line 1!".write f on_existing_file=Existing_File_Behavior.Error . should_equal Nothing
f.exists.should_be_true
f.read_text.should_equal "line 1!"
"line 2!".write f on_existing_file=Existing_File_Behavior.Error . should_fail_with File_Already_Exists_Error
f.read_text.should_equal 'line 1!'
f.delete
f.exists.should_be_false
Test.specify "should create a backup when writing a file" <|
f = transient / "work.txt"
f.delete_if_exists
f.exists.should_be_false
"line 1!".write f . should_equal Nothing
if f.exists.not then
Test.fail "The file should have been created."
f.read_text.should_equal "line 1!"
bak = transient / "work.txt.bak"
"backup content".write bak on_existing_file=Existing_File_Behavior.Overwrite
n0 = transient / "work.txt.new"
n1 = transient / "work.txt.new.1"
n2 = transient / "work.txt.new.2"
n3 = transient / "work.txt.new.3"
n4 = transient / "work.txt.new.4"
written_news = [n0, n1, n2, n4]
written_news.each n->
"new content".write n on_existing_file=Existing_File_Behavior.Overwrite
n3.delete_if_exists
"line 2!".write f . should_equal Nothing
f.read_text.should_equal 'line 2!'
bak.read_text.should_equal 'line 1!'
if n3.exists then
Test.fail "The temporary file should have been cleaned up."
written_news.each n->
n.read_text . should_equal "new content"
[f, bak, n0, n1, n2, n4].each .delete
Test.specify "should correctly handle failure of the write operation when working with the backup" <|
f = transient / "work.txt"
"OLD".write f on_existing_file=Existing_File_Behavior.Overwrite
bak_file = transient / "work.txt.bak"
new_file = transient / "work.txt.new"
[bak_file, new_file].each .delete_if_exists
result = Panic.recover Illegal_State_Error <|
Existing_File_Behavior.Backup.write f output_stream->
output_stream.write_bytes "foo".utf_8
Panic.throw (Illegal_State_Error "baz")
output_stream.write_bytes "bar".utf_8
result.should_fail_with Illegal_State_Error
result.catch.message . should_equal "baz"
f.read_text . should_equal "OLD"
if bak_file.exists then
Test.fail "If the operation failed, we shouldn't have even created the backup."
if new_file.exists then
Test.fail "The temporary file should have been cleaned up."
f.delete
result2 = Panic.recover Illegal_State_Error <|
Existing_File_Behavior.Backup.write f output_stream->
output_stream.write_bytes "foo".utf_8
Panic.throw (Illegal_State_Error "baz")
output_stream.write_bytes "bar".utf_8
result2.should_fail_with Illegal_State_Error
result2.catch.message . should_equal "baz"
if f.exists.not then
Test.fail "Since we were writing to the original destination, the partially written file should have been preserved even upon failure."
f.read_text . should_equal "foo"
if bak_file.exists then
Test.fail "If the operation failed, we shouldn't have even created the backup."
if new_file.exists then
Test.fail "The temporary file should have been cleaned up."
f.delete
Test.group "folder operations" <|
resolve files =
base = Enso_Project.data

View File

@ -29,7 +29,7 @@ spec =
f = Enso_Project.data / "short.txt"
f.delete_if_exists
f.exists.should_be_false
f.write_text "Cup"
"Cup".write f
java_charset = Encoding.utf_8.to_java_charset
f.with_input_stream [File.Option.Read] stream->
stream.with_java_stream java_stream->
@ -45,7 +45,7 @@ spec =
f = Enso_Project.data / "transient" / "varying_chunks.txt"
fragment = 'Hello 😎🚀🚧!'
contents = 1.up_to 1000 . map _->fragment . join '\n'
f.write_text contents
contents.write f
java_charset = Encoding.utf_8.to_java_charset
all_codepoints = Vector.new_builder
@ -104,7 +104,7 @@ spec =
f = Enso_Project.data / "transient" / "utf8.txt"
encoding = Encoding.utf_8
java_charset = encoding.to_java_charset
f.write_text ((0.up_to 100).map _->'Hello World!' . join '\n') Encoding.utf_8
((0.up_to 100).map _->'Hello World!' . join '\n').write f
expected_contents = f.read_text
contents = read_file_one_by_one f java_charset expected_contents.length
contents.should_equal expected_contents