diff --git a/src/Fable.Python.fsproj b/src/Fable.Python.fsproj index e518ff1..180e747 100644 --- a/src/Fable.Python.fsproj +++ b/src/Fable.Python.fsproj @@ -28,6 +28,7 @@ + diff --git a/src/stdlib/Pathlib.fs b/src/stdlib/Pathlib.fs new file mode 100644 index 0000000..4523c62 --- /dev/null +++ b/src/stdlib/Pathlib.fs @@ -0,0 +1,269 @@ +/// Type bindings for Python pathlib module: https://docs.python.org/3/library/pathlib.html +module Fable.Python.Pathlib + +open System +open Fable.Core + +// fsharplint:disable MemberNames + +/// Represents a filesystem path on the current OS (POSIX or Windows). +/// Paths are immutable; operations return new Path instances. +/// +/// `Path()` represents the current directory (`.`). For multi-segment construction +/// use the `/` operator (`Path "a" / "b" / "c"`) or `joinpath`. +/// See https://docs.python.org/3/library/pathlib.html#pathlib.Path +[] +type Path() = + /// Construct a Path from a single string segment. + [] + new(path: string) = Path() + + /// Construct a Path by joining multiple path segments. + /// Equivalent to ``Path(parts[0]) / parts[1] / …``. + [] + new([] paths: string[]) = Path() + + // ------------------------------------------------------------------------- + // Properties + // ------------------------------------------------------------------------- + + /// The final path component (file or directory name). + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.name + member _.name: string = nativeOnly + + /// The final path component without its last suffix (file extension). + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.stem + member _.stem: string = nativeOnly + + /// The last file extension of the final component (e.g. ".py"), or "" if none. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.suffix + member _.suffix: string = nativeOnly + + /// All file extensions of the final component (e.g. [".tar"; ".gz"]). + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.suffixes + member _.suffixes: string seq = nativeOnly + + /// The logical parent of the path (directory containing this path). + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.parent + member _.parent: Path = nativeOnly + + /// Immutable sequence of the logical ancestors of the path. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.parents + member _.parents: Path seq = nativeOnly + + /// The path's components as a tuple of strings. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.parts + member _.parts: string seq = nativeOnly + + /// The root component of the path (e.g. "/" on POSIX), or "". + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.root + member _.root: string = nativeOnly + + /// The concatenation of drive and root (e.g. "/" or "C:\\"), or "". + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.anchor + member _.anchor: string = nativeOnly + + /// The drive letter or name (relevant on Windows), or "". + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.drive + member _.drive: string = nativeOnly + + // ------------------------------------------------------------------------- + // Path arithmetic + // ------------------------------------------------------------------------- + + /// Return a new Path by appending a child string segment. + /// This mirrors Python's ``path / "child"`` operator. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.__truediv__ + [] + static member (/) (left: Path, right: string) : Path = nativeOnly + + /// Return a new Path by appending another Path. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.__truediv__ + [] + static member (/) (left: Path, right: Path) : Path = nativeOnly + + /// Join one or more path segments to this path and return the result. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.joinpath + [] + member _.joinpath([] parts: string[]) : Path = nativeOnly + + // ------------------------------------------------------------------------- + // Tests (pure — no I/O) + // ------------------------------------------------------------------------- + + /// Return True if the path is absolute (has both a root and, if applicable, a drive). + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.is_absolute + member _.is_absolute() : bool = nativeOnly + + /// Return True if this path is relative to other (3.9+). + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.is_relative_to + member _.is_relative_to(other: Path) : bool = nativeOnly + + /// Return True if this path is relative to the string path other (3.9+). + [] + member _.is_relative_to(other: string) : bool = nativeOnly + + // ------------------------------------------------------------------------- + // Transformations (pure — no I/O) + // ------------------------------------------------------------------------- + + /// Return a new path with the name component replaced. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.with_name + member _.with_name(name: string) : Path = nativeOnly + + /// Return a new path with the stem component replaced (3.9+). + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.with_stem + member _.with_stem(stem: string) : Path = nativeOnly + + /// Return a new path with the suffix component replaced. + /// Pass "" to remove the suffix. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.with_suffix + member _.with_suffix(suffix: string) : Path = nativeOnly + + /// Return a string representation of the path. + /// Equivalent to Python's ``str(path)``. + [] + member _.str() : string = nativeOnly + + /// Return the path as a POSIX string (forward slashes). + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.as_posix + member _.as_posix() : string = nativeOnly + + /// Return the path as a URI (file:// scheme). Only absolute paths are supported. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.as_uri + member _.as_uri() : string = nativeOnly + + // ------------------------------------------------------------------------- + // I/O queries + // ------------------------------------------------------------------------- + + /// Return True if the path points to an existing filesystem entry. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.exists + member _.exists() : bool = nativeOnly + + /// Return True if the path points to a regular file (follows symlinks). + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.is_file + member _.is_file() : bool = nativeOnly + + /// Return True if the path points to a directory (follows symlinks). + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.is_dir + member _.is_dir() : bool = nativeOnly + + /// Return True if the path points to a symbolic link. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.is_symlink + member _.is_symlink() : bool = nativeOnly + + /// Return True if the path is a mount point. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.is_mount + member _.is_mount() : bool = nativeOnly + + // ------------------------------------------------------------------------- + // I/O operations + // ------------------------------------------------------------------------- + + /// Make the path absolute and resolve any symlinks or ``..`` components. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.resolve + member _.resolve() : Path = nativeOnly + + /// Make the path relative to other, raising ValueError if impossible. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.relative_to + member _.relative_to(other: Path) : Path = nativeOnly + + /// Make the path relative to a string path. + [] + member _.relative_to(other: string) : Path = nativeOnly + + /// Return the decoded contents of the file as a string (UTF-8 by default). + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.read_text + member _.read_text() : string = nativeOnly + + /// Return the decoded contents of the file using the given encoding. + [] + member _.read_text(encoding: string) : string = nativeOnly + + /// Return the binary contents of the file as a bytes object. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.read_bytes + member _.read_bytes() : byte[] = nativeOnly + + /// Write a string to the file, returning the number of characters written. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.write_text + [] + member _.write_text(data: string) : int = nativeOnly + + /// Write a string to the file with the given encoding. + [] + member _.write_text(data: string, encoding: string) : int = nativeOnly + + /// Write binary data to the file, returning the number of bytes written. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.write_bytes + member _.write_bytes(data: byte[]) : int = nativeOnly + + /// Iterate over the directory contents, yielding Path objects. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.iterdir + member _.iterdir() : Path seq = nativeOnly + + /// Glob the given relative pattern in the directory, returning matching paths. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.glob + member _.glob(pattern: string) : Path seq = nativeOnly + + /// Like glob() but descends into sub-directories recursively. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.rglob + member _.rglob(pattern: string) : Path seq = nativeOnly + + /// Create this directory. Raises FileExistsError if it already exists. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.mkdir + member _.mkdir() : unit = nativeOnly + + /// Create this directory with options. + /// parents=true creates any missing parent directories. + /// exist_ok=true suppresses FileExistsError. + [] + member _.mkdir(mode: int, parents: bool, exist_ok: bool) : unit = nativeOnly + + /// Create this directory and all missing parents (equivalent to ``mkdir -p``). + [] + member _.mkdir_p() : unit = nativeOnly + + /// Remove this directory. The directory must be empty. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.rmdir + member _.rmdir() : unit = nativeOnly + + /// Remove this file (or symbolic link). Raises FileNotFoundError if missing. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.unlink + member _.unlink() : unit = nativeOnly + + /// Remove this file; if missing_ok is true, no error is raised. + [] + member _.unlink(missing_ok: bool) : unit = nativeOnly + + /// Rename the file or directory to target, returning the new Path. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.rename + member _.rename(target: Path) : Path = nativeOnly + + /// Rename the file or directory to a string target, returning the new Path. + [] + member _.rename(target: string) : Path = nativeOnly + + /// Rename to target, overwriting any existing destination. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.replace + member _.replace(target: Path) : Path = nativeOnly + + /// Rename to a string target, overwriting any existing destination. + [] + member _.replace(target: string) : Path = nativeOnly + + /// Expand the ``~`` user home directory shortcut. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.expanduser + member _.expanduser() : Path = nativeOnly + + // ------------------------------------------------------------------------- + // Static factory methods + // ------------------------------------------------------------------------- + + /// Return a new Path representing the current working directory. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.cwd + static member cwd() : Path = nativeOnly + + /// Return a new Path representing the user's home directory. + /// See https://docs.python.org/3/library/pathlib.html#pathlib.Path.home + static member home() : Path = nativeOnly diff --git a/test/Fable.Python.Test.fsproj b/test/Fable.Python.Test.fsproj index 8636ce7..3cf0beb 100644 --- a/test/Fable.Python.Test.fsproj +++ b/test/Fable.Python.Test.fsproj @@ -34,6 +34,7 @@ + diff --git a/test/TestPathlib.fs b/test/TestPathlib.fs new file mode 100644 index 0000000..25854d4 --- /dev/null +++ b/test/TestPathlib.fs @@ -0,0 +1,248 @@ +module Fable.Python.Tests.Pathlib + +open Fable.Python.Testing +open Fable.Python.Pathlib +open Fable.Python.Builtins + +// ============================================================================ +// Construction +// ============================================================================ + +[] +let ``test Path construction from string`` () = + let p = Path "/tmp" + p.str () |> equal "/tmp" + +[] +let ``test Path construction from multiple segments`` () = + let p = Path("/foo", "bar", "baz.txt") + p.str () |> equal "/foo/bar/baz.txt" + +[] +let ``test Path cwd returns absolute path`` () = + let p = Path.cwd () + p.is_absolute () |> equal true + +[] +let ``test Path home returns absolute path`` () = + let p = Path.home () + p.is_absolute () |> equal true + +// ============================================================================ +// Properties +// ============================================================================ + +[] +let ``test Path name`` () = + let p = Path "/foo/bar/baz.txt" + p.name |> equal "baz.txt" + +[] +let ``test Path stem`` () = + let p = Path "/foo/bar/baz.txt" + p.stem |> equal "baz" + +[] +let ``test Path suffix`` () = + let p = Path "/foo/bar/baz.txt" + p.suffix |> equal ".txt" + +[] +let ``test Path suffix absent`` () = + let p = Path "/foo/bar/README" + p.suffix |> equal "" + +[] +let ``test Path suffixes multiple`` () = + let p = Path "/foo/bar/archive.tar.gz" + p.suffixes |> Seq.toList |> equal [ ".tar"; ".gz" ] + +[] +let ``test Path parent`` () = + let p = Path "/foo/bar/baz.txt" + p.parent.str () |> equal "/foo/bar" + +[] +let ``test Path root on absolute path`` () = + let p = Path "/foo/bar" + p.root |> equal "/" + +[] +let ``test Path anchor`` () = + let p = Path "/foo/bar" + p.anchor |> equal "/" + +// ============================================================================ +// Path arithmetic +// ============================================================================ + +[] +let ``test Path slash operator with string`` () = + let p = Path "/foo" / "bar" + p.str () |> equal "/foo/bar" + +[] +let ``test Path slash operator chained`` () = + let p = Path "/foo" / "bar" / "baz" + p.str () |> equal "/foo/bar/baz" + +[] +let ``test Path slash operator with Path`` () = + let base' = Path "/foo" + let child = Path "bar" + let p = base' / child + p.str () |> equal "/foo/bar" + +[] +let ``test Path joinpath`` () = + let p = Path("/foo").joinpath ("bar", "baz") + p.str () |> equal "/foo/bar/baz" + +// ============================================================================ +// Transformations (pure) +// ============================================================================ + +[] +let ``test Path with_name`` () = + let p = Path "/foo/bar/baz.txt" + p.with_name("qux.txt").str () |> equal "/foo/bar/qux.txt" + +[] +let ``test Path with_stem`` () = + let p = Path "/foo/bar/baz.txt" + p.with_stem("qux").str () |> equal "/foo/bar/qux.txt" + +[] +let ``test Path with_suffix`` () = + let p = Path "/foo/bar/baz.txt" + p.with_suffix(".py").str () |> equal "/foo/bar/baz.py" + +[] +let ``test Path with_suffix empty removes extension`` () = + let p = Path "/foo/bar/baz.txt" + p.with_suffix("").str () |> equal "/foo/bar/baz" + +[] +let ``test Path as_posix`` () = + let p = Path "/foo/bar/baz.txt" + p.as_posix () |> equal "/foo/bar/baz.txt" + +// ============================================================================ +// is_* predicates +// ============================================================================ + +[] +let ``test Path is_absolute true`` () = + let p = Path "/foo/bar" + p.is_absolute () |> equal true + +[] +let ``test Path is_absolute false`` () = + let p = Path "foo/bar" + p.is_absolute () |> equal false + +[] +let ``test Path is_relative_to`` () = + let p = Path "/foo/bar/baz" + p.is_relative_to (Path "/foo") |> equal true + +[] +let ``test Path is_relative_to string`` () = + let p = Path "/foo/bar/baz" + p.is_relative_to "/foo" |> equal true + +[] +let ``test Path is_relative_to false`` () = + let p = Path "/foo/bar" + p.is_relative_to (Path "/baz") |> equal false + +// ============================================================================ +// I/O queries on a real temp file +// ============================================================================ + +[] +let ``test Path exists true for cwd`` () = + Path.cwd().exists () |> equal true + +[] +let ``test Path exists false for nonexistent`` () = + let p = Path "/nonexistent_repo_assist_xyz" + p.exists () |> equal false + +[] +let ``test Path is_dir true for cwd`` () = + Path.cwd().is_dir () |> equal true + +[] +let ``test Path is_file false for cwd`` () = + Path.cwd().is_file () |> equal false + +[] +let ``test Path resolve returns absolute`` () = + let p = Path "." + p.resolve().is_absolute () |> equal true + +[] +let ``test Path relative_to`` () = + let p = Path "/foo/bar/baz" + p.relative_to(Path "/foo/bar").str () |> equal "baz" + +[] +let ``test Path relative_to string`` () = + let p = Path "/foo/bar/baz" + p.relative_to("/foo/bar").str () |> equal "baz" + +// ============================================================================ +// File round-trip: write_text / read_text / unlink +// ============================================================================ + +[] +let ``test Path write_text and read_text`` () = + let tmp = Path.cwd () / "__pathlib_test_write__.txt" + tmp.write_text "hello pathlib" |> ignore + let content = tmp.read_text () + tmp.unlink (missing_ok = true) + content |> equal "hello pathlib" + +[] +let ``test Path write_bytes and read_bytes`` () = + let tmp = Path.cwd () / "__pathlib_test_bytes__.bin" + let data = builtins.bytes [| 0uy; 1uy; 2uy; 255uy |] + tmp.write_bytes data |> ignore + let result = tmp.read_bytes () + tmp.unlink (missing_ok = true) + result |> equal data + +[] +let ``test Path unlink missing_ok suppresses error`` () = + let tmp = Path "/nonexistent_repo_assist_unlink_xyz.txt" + // Should not raise + tmp.unlink (missing_ok = true) + true |> equal true + +// ============================================================================ +// Directory operations +// ============================================================================ + +[] +let ``test Path mkdir_p and rmdir`` () = + let dir = Path.cwd () / "__pathlib_test_dir__" + dir.mkdir_p () + dir.is_dir () |> equal true + dir.rmdir () + dir.exists () |> equal false + +[] +let ``test Path iterdir returns entries`` () = + let dir = Path.cwd () + let entries = dir.iterdir () |> Seq.toList + entries.Length > 0 |> equal true + +[] +let ``test Path glob`` () = + // Create a file, glob for it, clean up + let tmp = Path.cwd () / "__pathlib_glob_test__.txt" + tmp.write_text "glob" |> ignore + let matches = (Path.cwd ()).glob "__pathlib_glob_test__*.txt" |> Seq.toList + tmp.unlink (missing_ok = true) + matches.Length >= 1 |> equal true