Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add root to Path & root constructor #196

Merged
merged 13 commits into from
Oct 18, 2023
Merged

Conversation

szymon-rd
Copy link
Contributor

@szymon-rd szymon-rd commented Aug 7, 2023

Resolve #170

This PR adds a def root(root: String, fileSystem: FileSystem): Path in os package object. Additionally, it adds root and filesystem members to Path. It addresses two problems:

  • Specifying custom roots for a path
  • Using custom filesystems

Filesystem

Filesystem was added as a field of the os.Path. It was not added as a Root field for simplicity and consistency with Java's nio. Root is a part of the path; filesystem is the context of the whole path. One filesystem can have many roots; one root can have many subdirectories.

Root as String

A string is the simplest type that can represent the path's root. It also corresponds to the mental model of the path - usually, developers perceive it as a String. Therefore - String was chosen as the type of root.

Pull request: #196

@lefou lefou mentioned this pull request Aug 28, 2023
@lihaoyi
Copy link
Member

lihaoyi commented Sep 9, 2023

I think this looks reasonable. Is it possible to make the change binary compatible? Something like

val root: Path = ...
def root(s: String): Path = ...

lihaoyi pushed a commit that referenced this pull request Sep 12, 2023
Thanks for a great library! Hopefully this change will increase interest
among windows shell environment developers.

This fixes #201

The essence of the fix is to support paths with a leading slash (`'/' or
'\'`) on Windows.

In this PR, this path type is referred to as `driveRelative`, due to
subtle differences in how it is handled on `Windows` versus other
platforms.

Example code:
```scala
    val p1 = java.nio.file.Paths.get("/omg")
    printf("isAbsolute: %s\n", p1.isAbsolute)
    printf("%s\n", p1)
    printf("%s\n", os.Path("/omg"))
```
Output on Linux or OSX:
```sh
isAbsolute: true
/omg
/omg
```
On Windows:
```scala
isAbsolute: false
\omg
java.lang.IllegalArgumentException: requirement failed: \omg is not an absolute path
        at os.Path.<init>(Path.scala:474)
        at os.Path$.apply(Path.scala:426)
        at oslibChek$package$.main(oslibChek.sc:11)
        at oslibChek$package.main(oslibChek.sc)
```
### Background
On Windows, a `driveRelative` path is considered relative because a
drive letter prefix is required to fully resolve it.
Like other platforms, Windows also supports `posix relative` paths,
which are relative to the `current working directory`.

Because all platforms, including `Windows`, support absolute paths and
`posix relative` paths, it's convenient when writing
platform-independent code to view `driveRelative` paths as absolute
paths, as they are on other platforms.

On Windows, the `current working drive` is an immutable value that is
captured on jvm startup. Therefore, a `driveRelative` path on Windows
unambiguously refers to a unique file with a hidden drive letter.

This PR treats `driveRelative` paths as absolute paths in Windows, and
has no effect on other platforms.

#### Making `os/test/src/PathTests` platform independent

This PR enables `Unix()` tests in `os/test/src/PathTests.scala` so they
also verify required semantics in Windows.

### How this PR relates to #170 and #196
The purpose of #196 seems to be to add full support on Windows, without
resorting to `java.nio` classes and methods.
The addition of `os.root(...)` or `os.drive(...)` would be convenient
for people writing windows-only code, whereas this PR is intended to
allow writing platform-independent code, so the concerns seem to be
orthogonal.

It seems important not to alter `Path.segments` in a way that is
incompatible with `java.nio` segments.
 
An alternate way to preserve drive letter information would be to add a
method to one of the `Path.scala` traits that returns a drive letter if
appropriate on `Windows` and an empty String elsewhere.

```scala
trait PathChunk {
  def segments: Seq[String]
  def rootPrefix: String  // empty String, or Windows drive letter
  def ups: Int
}
```

With this approach, the following assertion would be valid on all
platforms:

```scala
os.Path(pwd.rootPrefix) ==> pwd
```

Then `driveRoot` in this PR would become `pwd.rootPrefix`.

There is another (perhaps never used?) very subtle feature of `Windows`
filesystem: each drive has a different value for `pwd`. It may not be
necessary to model this in `os-lib`, since these values are immutable in
a running jvm, and there are existing workarounds.


### Additional Details regarding unique aspects of Windows Filesystem
Paths
For completeness, the following lengthy `scala` REPL session illustrates
Path values returned by `java.nio.file.Paths.get()`, some of which might
be surprising.
<details>

Relevant information: My system drives are C: and F:, but not J:

First, some unsurprising results:
```scala
c:\work-directory> scala.bat
scala> import java.nio.file.Paths
Welcome to Scala 3.3.1-RC5 (17.0.2, Java OpenJDK 64-Bit Server VM).
Type in expressions for evaluation. Or try :help.

scala> Paths.get("/")
val res0: java.nio.file.Path = \

scala> Paths.get("/").isAbsolute
val res2: Boolean = false

scala> Paths.get("/").toAbsolutePath
val res1: java.nio.file.Path = C:\

scala> Paths.get("c:/").toAbsolutePath
C:\work-directory

scala> Paths.get("f:/").toAbsolutePath
F:\

scala> Paths.get("f:/..").normalize.toAbsolutePath
f:\

scala> Paths.get("c:/..").normalize.toAbsolutePath
c:\
```
Now some less obvious results:
```scala
scala> Paths.get("c:").toAbsolutePath
C:\work-directory

scala> Paths.get("f:").toAbsolutePath
F:\

scala> Paths.get("f:/..").normalize.toAbsolutePath
f:\

scala> Paths.get("c:/..").normalize.toAbsolutePath
c:\

scala> Paths.get("c:..").normalize.toAbsolutePath
C:\work-directory

scala> Paths.get("f:..").normalize.toAbsolutePath
F:\..

scala> Paths.get("F:Users")
val res1: java.nio.file.Path = F:Users

scala> Paths.get("F:Users").toAbsolutePath
val res2: java.nio.file.Path = F:\work-directory\Users

scala> Paths.get("j:").toAbsolutePath
java.io.IOError: java.io.IOException: Unable to get working directory of drive 'J'
  at java.base/sun.nio.fs.WindowsPath.toAbsolutePath(WindowsPath.java:926)
  at java.base/sun.nio.fs.WindowsPath.toAbsolutePath(WindowsPath.java:42)
  ... 35 elided
Caused by: java.io.IOException: Unable to get working directory of drive 'J'
  ... 37 more
```

The error message returned for a non-existing drive seems to imply that
existing drives have a `working directory`.
This is consistent with the `Windows API` as described here:

[GetFullPathNameA](https://learn.microsoft.com/en-gb/windows/win32/api/fileapi/nf-fileapi-getfullpathnamea)

I can set the `working drive` for F: in a `CMD` session before I start
up the `jvm`, and it does affect the output of `Paths.get()`.
```CMD
c:\work-directory>F:

f:\>cd work-directory

F:\work-directory>scala.bat
Welcome to Scala 3.3.1-RC5 (17.0.2, Java OpenJDK 64-Bit Server VM).
Type in expressions for evaluation. Or try :help.

scala> import java.nio.file.Paths

scala> Paths.get("/")
val res0: java.nio.file.Path = \

scala> Paths.get("/").toAbsolutePath
val res1: java.nio.file.Path = C:\

scala> Paths.get("f:")
val res2: java.nio.file.Path = f:

scala> Paths.get("f:").toAbsolutePath
val res3: java.nio.file.Path = F:\work-directory

scala> Paths.get("c:").toAbsolutePath
val res4: java.nio.file.Path = C:\work-directory

scala> Paths.get("c:")
val res5: java.nio.file.Path = c:
```

Regarding the interpretation of a drive letter expression not followed
by a '/' or '\\', the
rule is that it represents the `working directory` for that drive.

These values are immutable: after the `JVM` starts up it's not possible
to change a drive `working directory`.
</details>
@szymon-rd
Copy link
Contributor Author

@lihaoyi seems to work all right

@szymon-rd
Copy link
Contributor Author

@lihaoyi I pushed a commit that adds a separate field filesystem to the Path. I'd argue that it is a separate entity from the root. I propose a model where a root can be thought of as just a string that defines the root element of a path, a filesystem can have many roots and is not a member of a root.

val fileSystem = FileSystems.newFileSystem(uri, env);
val p = os.root("/", fileSystem) / "test" / "dir"
assert(p.root == "/")
assert(p.fileSystem == fileSystem)
Copy link
Member

@lihaoyi lihaoyi Oct 11, 2023

Choose a reason for hiding this comment

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

Could we add a few more operations here? e.g.

  1. Running more operations we expect should work on foo.jar: os.read, os.read.*, os.write, os.write.*, os.list, os.walk, os.remove, os.remove.all, os.copy, os.move, os.makeDir

  2. Some negative examples of running operations that we should expect to fail when run on an os.Path inside foo.jar: os.symlink, os.temp with an explicit dir: os.Path, os.stat, os.perms, os.proc() passing the os.Path inside foo.jar both as a os.Shellable and cwd/stdin/stdout/stderr. Assert on the exception type so we know what we're getting back on failure; the underlying java.nio exception is fine for the cases where things already fail when they should, though some things like os.proc we may have to check and throw our own exception

  3. Further manipulation of the paths with a custom filesystem via os.up, / os.RelPath, / os.SubPath, and ensuring the root ends up in the same place

  4. Checking that os.Paths with the same segments but different roots or different filesystem do not return == to each other

  5. os.relativeTo and os.subRelativeTo between paths on different filesystems should probably throw an exception, so let's assert that. Should they also throw exceptions when run between paths on different roots on the same filesystem?

This is enough stuff that it's probably worth moving the test suite into a separate file

@lihaoyi
Copy link
Member

lihaoyi commented Oct 11, 2023

@szymon-rd looks good! Last comments on my side and then we can merge this:

  1. Flesh out the test suite; left a comment there with more details. That will help cover some of the edge cases and really define what we expect to work and not work when dealing with different roots and different filesystems. It's ok if not everything works and some things throw exceptions, but we should be aware of where the boundary is

  2. Need to get the Windows CI passing

  3. Update the PR description with an explanation of the change: what you did, why you chose this approach over others (e.g. choosing to have a separate filesystem field vs having the root be a java.nio.file.Path)

  4. Add a section to the readme. This could either go inside the os.Path section as a subsection Roots and Filesystems, to cover what is (a) expected to work and (b) expected to not work when people are dealing with paths on different roots and filesystems. Basically a english summary of the test suite above

It's a small code change, but with pretty wide interaction surface area with the rest of the library, so we should make sure it's reasonably thoroughly tested and documented so people can know what to expect when they inevitably start hitting edge cases. Once that's done we can merge this and cut a point release

@szymon-rd
Copy link
Contributor Author

@lihaoyi I think I've addressed all of your comments

@lihaoyi
Copy link
Member

lihaoyi commented Oct 16, 2023

Looks good to me. Thanks for working through all the feedback! It was definitely a lot of back and forth.

It seems MIMA is complaining, but I suspect the problem is Scala 2.11.x only, since the encoding of traits changed between 2.11 and 2.12:

Found 1 issue when checking against com.lihaoyi:os-lib_2.11:0.9.0
 * method isCustomFs(java.lang.Object)Boolean in trait os.PathConvertible is present only in current version
   filter with: ProblemFilter.exclude[ReversedMissingMethodProblem]("os.PathConvertible.isCustomFs")
Found 1 issue when checking against com.lihaoyi:os-lib_2.11:0.9.1
 * method isCustomFs(java.lang.Object)Boolean in trait os.PathConvertible is present only in current version
   filter with: ProblemFilter.exclude[ReversedMissingMethodProblem]("os.PathConvertible.isCustomFs")

I think I'd be ok with with just adding the MIMA exclusion to shut it up, or just turning off MIMA for 2.11 entirely. 2.11 support is already pretty best-effort-ish (we removed it once in the past and added it back), so I don't feel like we should contort the code to preserve strict compatibility there just for 2.11.x.

@lolgab @lefou let me know if you have any opinion on this, otherwise I will add a MIMA exclusion and merge this.

@SethTisue
Copy link
Contributor

SethTisue commented Oct 16, 2023

2.11 support is already pretty best-effort-ish (we removed it once in the past and added it back)

I wondered about the history on this, but I didn't find anything except #66 (which didn't come with an explanation) and #47 (which the requester withdrew).

If y'all are considering simply dropping it again, I encourage you to do so.

@lefou
Copy link
Member

lefou commented Oct 16, 2023

FS Root support, esp. for Windows, was my personal blocker for a 1.0 release. Now, it looks like that we can even keep binary compatibility, which is really nice. I think, we should cut a 0.9.2 after we merged it. After that, we may focus on cleaning the API and the docs for a 1.0. In 1.0 we can drop support for Scala 2.11 and also bump our dependencies to Scala.js and Scala Native to newer versions (which we currently don't due to, again, compatibility).

@lihaoyi
Copy link
Member

lihaoyi commented Oct 18, 2023

Added a mima exclude, will squashmerge once green

@lihaoyi lihaoyi merged commit af2fd36 into com-lihaoyi:main Oct 18, 2023
9 checks passed
@lihaoyi
Copy link
Member

lihaoyi commented Oct 18, 2023

@szymon-rd let me know if you'd like me to tag a release, or if you want to wait for your other PR to land first before tagging one

@szymon-rd
Copy link
Contributor Author

I'd rather have the piping go into the release too

@lefou lefou added this to the after 0.9.1 milestone Oct 18, 2023
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 root in Path and support for roots for Windows
5 participants