Skip to content

Conversation

@fiolj
Copy link
Contributor

@fiolj fiolj commented Jan 5, 2026

This PR aims to add optional arguments to savetxt, that behave similar to numpy's savetxt.

This is associated with Issue 263 and this discussion thread.

It add the possibility of supplying the unit of an open file instead of a filename (which could be used for output_unit for instance)

This implementation is quite simple. The main changes are:

  1. Add optional header and footer (that could be commented out with a character, currently defaults to '#')
  2. Changes delimiter to an arbitrary length string. I am not completely sure that is really useful. Initially I've added this because np.savetxt (Numpy's) allows character or arbitrary strings but np.loadtxt requires it to be a length 1 char.
  3. Add the possibility to override default format for saving data

@codecov
Copy link

codecov bot commented Jan 5, 2026

Codecov Report

❌ Patch coverage is 91.37931% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.66%. Comparing base (de3e59f) to head (20c1a3f).
⚠️ Report is 21 commits behind head on master.

Files with missing lines Patch % Lines
example/io/example_savetxt.f90 0.00% 4 Missing ⚠️
example/linalg/example_solve_custom.f90 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1085      +/-   ##
==========================================
+ Coverage   68.55%   68.66%   +0.10%     
==========================================
  Files         396      396              
  Lines       12746    12803      +57     
  Branches     1376     1381       +5     
==========================================
+ Hits         8738     8791      +53     
- Misses       4008     4012       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jalvesz
Copy link
Contributor

jalvesz commented Jan 6, 2026

Thanks for this @fiolj. Would you mind reverting the changes not related to the implementation? (styling) There are too many and it renders difficult to read through the PR. You can check the style_guide for info https://github.com/fortran-lang/stdlib/blob/master/STYLE_GUIDE.md

One thing, white spaces in-between parentheses and an intrinsic function are not recommended ( open (...), allocate (...), etc )

@jalvesz jalvesz linked an issue Jan 6, 2026 that may be closed by this pull request
@fiolj
Copy link
Contributor Author

fiolj commented Jan 6, 2026

Thanks @jalvesz, I've fixed the formatting

Copy link
Member

@jvdp1 jvdp1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @fiolj . Here are some suggestions


s = open(filename, "w")
!! Write the header if non-empty
! prepend function may be replaced by use of replace_all but currently stdlib_strings
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is probably something that we should solve at a later stage.

@fiolj
Copy link
Contributor Author

fiolj commented Jan 15, 2026

Reviewing the arguments of savetxt I've rechecked and Numpy's has the following order:
np.savetxt(fname, X, fmt, delimiter, newline, header, footer, comments, encoding)

Currently, we have the order of fmt and delimiter inverted. Should we make it as Numpy or should we keep it?

@jvdp1
Copy link
Member

jvdp1 commented Jan 19, 2026

Reviewing the arguments of savetxt I've rechecked and Numpy's has the following order: np.savetxt(fname, X, fmt, delimiter, newline, header, footer, comments, encoding)

Currently, we have the order of fmt and delimiter inverted. Should we make it as Numpy or should we keep it?

As savetxt is still experimental and as both fmt and delimiter are optional, I think that inverting both args is doable.

@fiolj
Copy link
Contributor Author

fiolj commented Jan 19, 2026

Reviewing the arguments of savetxt I've rechecked and Numpy's has the following order: np.savetxt(fname, X, fmt, delimiter, newline, header, footer, comments, encoding)
Currently, we have the order of fmt and delimiter inverted. Should we make it as Numpy or should we keep it?

As savetxt is still experimental and as both fmt and delimiter are optional, I think that inverting both args is doable.

Thanks, I agree. Last uploaded commit has the new order and some small cleanup of the code.
Following the suggestion of @certik in the thread I've searched savetxt in numpy's commit history and found no issues or complains related to these arguments.

I think that it this version is close to apt to be merged, except for the codecov failings (which I have no idea what they mean).

@jvdp1
Copy link
Member

jvdp1 commented Jan 19, 2026

xcept for the codecov failings (which I have no idea what they mean).

It is related to the examples that are not covered by these pipelines. I think that @jalvesz got a similar issue with another PR.
It should not stop us for merging this PR (provided it is not related to the changes proposed in this PR).

@jalvesz
Copy link
Contributor

jalvesz commented Jan 19, 2026

The fails with codecov are valid in my opinion. In #1074 we avoided codecov from analyzing the examples as a metric for coverage. But in this case, it is a valid fail because there are API changes which are not being tested. It is not enough to just add an example to ensure correctness.

@jvdp1
Copy link
Member

jvdp1 commented Jan 19, 2026

The fails with codecov are valid in my opinion. In #1074 we avoided codecov from analyzing the examples as a metric for coverage. But in this case, it is a valid fail because there are API changes which are not being tested.

I am confused. This shows that some lines of example_savetxt are not covered. Or do I miss-interpret the report?

It is not enough to just add an example to ensure correctness.

I agree with you.

@jvdp1
Copy link
Member

jvdp1 commented Jan 19, 2026

I think that it this version is close to apt to be merged, except for the codecov failings (which I have no idea what they mean).

@fiolj could you update the tests to cover the different changes in the API?

@fiolj
Copy link
Contributor Author

fiolj commented Jan 19, 2026

I think that it this version is close to apt to be merged, except for the codecov failings (which I have no idea what they mean).

@fiolj could you update the tests to cover the different changes in the API?

Yes. I've commited some tests.
I noticed that loadtxt is currently setting delimiter to a single char, thus I've kept the same convention to savetxt.
It is quite easy to change savetxt to arbitrary length strings; I would wait until loadtxt is also modified.

@jalvesz
Copy link
Contributor

jalvesz commented Jan 20, 2026

I am confused. This shows that some lines of example_savetxt are not covered. Or do I miss-interpret the report?

I'm also confused now, codecov is supposed to ignore files in the examples directory after the PR #1074 ... I would say for the moment to ignore this false error.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds optional arguments to the savetxt function to provide more flexibility when saving 2D arrays to text files, making it more similar to NumPy's savetxt functionality.

Changes:

  • Added ability to specify an already-opened file unit instead of a filename, enabling writing to stdout or appending to files
  • Added optional header and footer parameters that are automatically commented with a configurable comment character
  • Added optional fmt parameter to customize output format
  • Modified format string construction to support custom delimiters (though delimiter parameter still limited to single character)

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 18 comments.

File Description
src/io/stdlib_io.fypp Core implementation: generates two variants of savetxt (filename and unit), adds format customization, header/footer support via new prepend helper function
test/io/test_savetxt.f90 Updated tests to exercise new delimiter, fmt, header, footer, and comments parameters
doc/specs/stdlib_io.md Updated documentation to describe new optional parameters and unit-based interface
example/io/example_savetxt.f90 Added examples demonstrating new features including writing to stdout

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

default_fmt = FMT_REAL_${k1}$(2:len(FMT_REAL_${k1}$)-1)
#:elif 'complex' in t1
fmt_ = "(*"//FMT_COMPLEX_${k1}$(1:11)//delim_str//FMT_COMPLEX_${k1}$(14:23)//",:,"//delim_str//"))"
default_fmt = FMT_COMPLEX_${k1}$(2:11)//delim_str//FMT_COMPLEX_${k1}$ (14:23)
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The substring indices for extracting the second format descriptor for complex numbers are incorrect. For FMT_COMPLEX_SP = '(es15.08e2,1x,es15.08e2)', the substring (14:23) starts at the comma and extracts ,es15.08e2, which includes an unwanted leading comma. The correct indices should be (15:23) to extract just es15.08e2. The same issue affects all complex kinds (DP, XDP, QP) with appropriately adjusted indices based on their format string lengths.

Suggested change
default_fmt = FMT_COMPLEX_${k1}$(2:11)//delim_str//FMT_COMPLEX_${k1}$ (14:23)
default_fmt = FMT_COMPLEX_${k1}$(2:11)//delim_str//FMT_COMPLEX_${k1}$ (15:23)

Copilot uses AI. Check for mistakes.

`footer`: (optional) Shall be a character expression that will be written at the end of the file.

`comments`: (optional) : Shall be a character expression that will be prepended to the ``header`` and ``footer`` strings to mark them as comments. Default: `#`.
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an extra colon and space after comments in the parameter description. It should be comments (optional): not comments: (optional) : for consistency with other parameter descriptions in the documentation.

Suggested change
`comments`: (optional) : Shall be a character expression that will be prepended to the ``header`` and ``footer`` strings to mark them as comments. Default: `#`.
`comments` (optional): Shall be a character expression that will be prepended to the ``header`` and ``footer`` strings to mark them as comments. Default: `#`.

Copilot uses AI. Check for mistakes.
! prepend function may be replaced by use of replace_all but currently stdlib_strings
! is being compiled after stdlib_io
! if (header_ /= '') write (unit, '(A)') comments_//replace_all(header_, nl, nl//comments_)
if (header_ /= '') write (unit, '(A)', iostat=ios, iomsg=iomsg) prepend(header_, comments_)
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The write statement for the header does not check the iostat value after writing. If the write fails (e.g., due to permissions or disk space issues), the error is silently ignored and execution continues. Consider adding error handling similar to the data writing loop (lines 319-327) to ensure write failures are caught and reported.

Suggested change
if (header_ /= '') write (unit, '(A)', iostat=ios, iomsg=iomsg) prepend(header_, comments_)
if (header_ /= '') then
write (unit, '(A)', iostat=ios, iomsg=iomsg) prepend(header_, comments_)
if (ios /= 0) then
write (msgout,1) trim(iomsg),size(d,2),0,&
#:if 'filename' in arg1
trim(filename)
#:else
unit
#:endif
call error_stop(msg=trim(msgout))
end if
end if

Copilot uses AI. Check for mistakes.
close(s)

! if (footer_ /= '') write (unit, '(A)') comments_//replace_all(footer_, nl, nl//comments_)
if (footer_ /= '') write (unit, '(A)') prepend(footer_, comments_)
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The write statement for the footer does not check the iostat value after writing. If the write fails (e.g., due to permissions or disk space issues), the error is silently ignored. Consider adding error handling similar to the data writing loop (lines 319-327) to ensure write failures are caught and reported.

Suggested change
if (footer_ /= '') write (unit, '(A)') prepend(footer_, comments_)
if (footer_ /= '') then
write (unit, '(A)', iostat=ios, iomsg=iomsg) prepend(footer_, comments_)
if (ios /= 0) then
write (msgout,'(a)') trim(iomsg)
call error_stop(msg=trim(msgout))
end if
end if

Copilot uses AI. Check for mistakes.
#:endif
${t1}$, intent(in) :: d(:,:) ! The 2D array to save
character(len=1), intent(in), optional :: delimiter ! Column delimiter. Default is a space.
character(len=1), intent(in), optional :: delimiter ! Column delimiter. Default is a space ' '.
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The delimiter parameter is still constrained to character(len=1), but the PR description states that one goal is to "change delimiter to an arbitrary length string" to support delimiters like ', ' (comma followed by spaces). This inconsistency means the feature is incomplete. Consider changing the delimiter parameter type to character(len=*) to match the stated goal and documentation changes.

Suggested change
character(len=1), intent(in), optional :: delimiter ! Column delimiter. Default is a space ' '.
character(len=*), intent(in), optional :: delimiter ! Column delimiter. Default is a space ' '.

Copilot uses AI. Check for mistakes.
default_fmt = FMT_REAL_${k1}$(2:len(FMT_REAL_${k1}$)-1)
#:elif 'complex' in t1
fmt_ = "(*"//FMT_COMPLEX_${k1}$(1:11)//delim_str//FMT_COMPLEX_${k1}$(14:23)//",:,"//delim_str//"))"
default_fmt = FMT_COMPLEX_${k1}$(2:11)//delim_str//FMT_COMPLEX_${k1}$ (14:23)
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an extra space before the opening parenthesis in FMT_COMPLEX_${k1}$ (14:23). This should be FMT_COMPLEX_${k1}$(14:23) without the space to avoid a syntax error when the template is expanded.

Suggested change
default_fmt = FMT_COMPLEX_${k1}$(2:11)//delim_str//FMT_COMPLEX_${k1}$ (14:23)
default_fmt = FMT_COMPLEX_${k1}$(2:11)//delim_str//FMT_COMPLEX_${k1}$(14:23)

Copilot uses AI. Check for mistakes.

`fmt`: (optional): Fortran format specifier for the text save. Defaults to the write format for the data type.

`header`: (optional) Shall be a character expression that will be written at the beginning of the file.
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an extra colon after header in the parameter description. It should be header (optional): not header: (optional) for consistency with other parameter descriptions in the documentation.

Suggested change
`header`: (optional) Shall be a character expression that will be written at the beginning of the file.
`header` (optional): Shall be a character expression that will be written at the beginning of the file.

Copilot uses AI. Check for mistakes.
Comment on lines 113 to 114
call savetxt(outpath, e, fmt='(g0.7)', header="Three values per line", footer="Total size = 6", comments='#!')
call loadtxt(outpath, d2, fmt='*', skiprows=1, max_rows=2)
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new unit-based interface for savetxt is not tested. The example code includes a call to savetxt(output_unit, x, header='...'), but there are no automated tests verifying this functionality works correctly. Consider adding a test case that opens a file with a specific unit number, writes to it using the unit variant of savetxt, closes it, and then verifies the content was written correctly.

Copilot uses AI. Check for mistakes.
e = reshape([1, 2, 3, 4, 5, 6], [2, 3])
call savetxt(outpath, e)
call loadtxt(outpath, d2)
call savetxt(outpath, e, fmt='(g0.7)', header="Three values per line", footer="Total size = 6", comments='#!')
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test uses comments='#!' (two characters), but the comments parameter documentation states it's for "a character expression" and defaults to #. The implementation allows arbitrary length comment strings (via optval(comments, comment_default) where comments is character(len=*)), which is good, but this should be explicitly documented. The current documentation at line 127 is unclear about whether multiple characters are supported.

Suggested change
call savetxt(outpath, e, fmt='(g0.7)', header="Three values per line", footer="Total size = 6", comments='#!')
call savetxt(outpath, e, fmt='(g0.7)', header="Three values per line", footer="Total size = 6", comments='#')

Copilot uses AI. Check for mistakes.
Comment on lines 113 to 114
call savetxt(outpath, e, fmt='(g0.7)', header="Three values per line", footer="Total size = 6", comments='#!')
call loadtxt(outpath, d2, fmt='*', skiprows=1, max_rows=2)
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test saves data with header and footer, but only validates that the data can be read back using skiprows=1 to skip the header and max_rows=2 to read only the data rows. The footer is never verified to have been written correctly. Consider adding a test that reads the entire file as text and verifies both header and footer are present and correctly formatted with comment characters.

Copilot uses AI. Check for mistakes.
Copy link
Member

@jvdp1 jvdp1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank oyu @fiolj . Overall it looks good. Here are some minor suggestions.

`delimiter` (optional): Shall be a character expression of length 1 that contains the delimiter used to separate the columns. The default is `' '`.
`delimiter` (optional): Shall be a character expression of any length that contains the delimiter used to separate the columns. The default is a single space `' '`.

`fmt`: (optional): Fortran format specifier for the text save. Defaults to the write format for the data type.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the write format?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It currently is (and was also in the original) the (may be little) documented Formatting constants. I've added the reference now in the spec

!!
!! Saves a 2D array into a text file
!! ([Specification](../page/specs/stdlib_io.html#description_2))
#:for a1 in ['f', 'u']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#:for a1 in ['f', 'u']
#:for arg1 in ['f'ilename, 'unit']

#:for a1 in ['f', 'u']
#:for k1, t1 in KINDS_TYPES
module procedure savetxt_${t1[0]}$${k1}$
module procedure savetxt_${t1[0]}$${k1}$${a1}$
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
module procedure savetxt_${t1[0]}$${k1}$${a1}$
module procedure savetxt_${t1[0]}$${k1}$${arg1}$

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can use arg1 but I was thinking on only using the first letter to identify the name of the routine, as in the definition (arg1[0]):

#:for arg1 in ['filename', 'unit']
#:for k1, t1 in KINDS_TYPES
subroutine savetxt_${t1[0]}$${k1}$${arg1[0]}$ (${arg1}$, d, delimiter, fmt, header, footer, comments)

character(len=*), intent(in) :: Sin
character(len=:), allocatable :: Sout
character(len=*), intent(in) :: comment
character(len=len(comment)+2) :: com_
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why "+2"? It seems that only 1 space is attended at the end of comment?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, corrected

d = reshape([1, 2, 3, 4, 5, 6], [3, 2])
call savetxt(outpath, d)
call loadtxt(outpath, d2)
call savetxt(outpath, d, delimiter=',')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we keep the initial tests, and add new ones for delimiter=','?

e = reshape([1, 2, 3, 4, 5, 6], [2, 3])
call savetxt(outpath, e)
call loadtxt(outpath, d2)
call savetxt(outpath, e, fmt='(g0.7)', header="Three values per line", footer="Total size = 6", comments='#!')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here: could we keep the initial tests and add new ones for delimiter='``?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, please check if the new tests are ok if you have time

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add optional arguments to savetxt

3 participants