Repository: yt-project/yt

Issue:
Use `fn`, `filename`, `name`, or `my_cool_argname` in the codebase? 
This was brought up in yt-project/yt#2877 and yt-project/yt#2695 . 

The second PR in that list makes the arguments passed to various loader functions consistent. Following other examples in our codebase, `fn` is used as the name of the variable to which we assign the input file (or folder). `fn` does (intentionally, I think, though somebody can correct me) create an ambiguity (between file name and folder name?) since we're not necessarily assuming a file is going to be passed into these loaders. Because we can have a folder passed, `filename` isn't the best name to use because it isn't always correct (same with foldername). The `load_sample` function addressed this by using `name` as this argument's name. 

Does anybody have any suggestions of what to use? I personally think `fn` is misleading because I read it is "function" if I'm scanning code. Is there something better?  

Maybe this is bikeshedding, but since load is what essentially all of our users use to access yt I think choosing a clearer argument name is important. 


Discussion:
`fname` is another option that I think we can all agree on rejecting immediately since it's used everywhere to mean "field name".
I would love for this argument to be positional only so that we don't have to embrace a bad name forever. But that's not possible until Python 3.8 becomes our oldest supported Python version (so somewhere along the release of Python 3.10 in October 2021, maybe ?)
I'm thinking `path` would be a good candidate. It's clear, and agnostic with respect to the file/directory dichotomy. Plus we should soon have support for os.Pathlike objects everywhere in the user-facing api, so this shouldn't cause unnecessary confusion
(https://github.com/yt-project/yt/pull/2722 solves the remaining bit where that's not done yet)
`fname` seems to be the dominant convention in numpy for reference (https://numpy.org/doc/stable/search.html?q=fname)
So, after pondering about this for a while, I think I found a solution that allows to progressively migrate function signatures to use positional-only arguments *without breaking users immediately*, i.e. detecting deprecated usage and emit warnings without altering results, allowing a clean deprecation cycle.

Here's my solution using decorators
https://gist.github.com/neutrinoceros/3f703d63761794b5e966330d51436b3c

This means we could make the first argument of every loader function a "future positional-only" argument and never have to worry about breaking users if we ever want to change its name.

For instance, we could decorate `yt.load` for a minor release or two (maybe longer ?)
```python
@future_positional_only({0: "fn"})
def load(fn, *args, **kwargs):
    ...
```
and in the future change it to
```python
def load(fn, /, *args, **kwargs):
    ...
```
note that we've just dropped support for Python 3.6, so we're roughly a year away from dropping support for Python 3.7, and we can't possibly use positional-only arguments before that. So I guess what I'm proposing is to introduce these warnings now, which would help closing this issue. What do you guys think, @munkm @jzuhone ?

Resolving patch:
diff --git a/yt/_maintenance/deprecation.py b/yt/_maintenance/deprecation.py
--- a/yt/_maintenance/deprecation.py
+++ b/yt/_maintenance/deprecation.py
@@ -1,5 +1,7 @@
 import warnings
-from typing import Optional
+from functools import wraps
+from types import FunctionType
+from typing import Dict, Optional
 
 
 class VisibleDeprecationWarning(UserWarning):
@@ -48,3 +50,30 @@ def issue_deprecation_warning(
     else:
         msg += f"in yt {removal}"
     warnings.warn(msg, VisibleDeprecationWarning, stacklevel=stacklevel)
+
+
+def future_positional_only(positions2names: Dict[int, str], /, **depr_kwargs):
+    """Warn users when using a future positional-only argument as keyword.
+    Note that positional-only arguments are available from Python 3.8
+    See https://www.python.org/dev/peps/pep-0570/
+    """
+
+    def outer(func: FunctionType):
+        @wraps(func)
+        def inner(*args, **kwargs):
+            for no, name in sorted(positions2names.items()):
+                if name not in kwargs:
+                    continue
+                value = kwargs[name]
+                issue_deprecation_warning(
+                    f"Using the {name!r} argument as keyword (on position {no}) "
+                    "is deprecated. "
+                    "Pass the argument as positional to supress this warning, "
+                    f"i.e., use {func.__name__}({value!r}, ...)",
+                    **depr_kwargs,
+                )
+            return func(*args, **kwargs)
+
+        return inner
+
+    return outer
diff --git a/yt/loaders.py b/yt/loaders.py
--- a/yt/loaders.py
+++ b/yt/loaders.py
@@ -18,6 +18,7 @@
 import numpy as np
 from more_itertools import always_iterable
 
+from yt._maintenance.deprecation import future_positional_only
 from yt.data_objects.static_output import Dataset
 from yt.funcs import levenshtein_distance
 from yt.sample_data.api import lookup_on_disk_data
@@ -41,6 +42,7 @@
 # --- Loaders for known data formats ---
 
 
+@future_positional_only({0: "fn"}, since="4.2.0")
 def load(
     fn: Union[str, "os.PathLike[str]"], *args, hint: Optional[str] = None, **kwargs
 ):


Test patch:
diff --git a/yt/tests/test_load_errors.py b/yt/tests/test_load_errors.py
--- a/yt/tests/test_load_errors.py
+++ b/yt/tests/test_load_errors.py
@@ -1,5 +1,6 @@
 import pytest
 
+from yt._maintenance.deprecation import VisibleDeprecationWarning
 from yt.data_objects.static_output import Dataset
 from yt.geometry.grid_geometry_handler import GridIndex
 from yt.loaders import load, load_simulation
@@ -158,3 +159,42 @@ def test_load_ambiguous_data_with_hint(hint, expected_type, tmp_path):
 
     ds = ts[1]
     assert type(ds).__name__ == expected_type
+
+
+@pytest.fixture()
+def catchall_dataset_class():
+    # define a Dataset class matching any input,
+    # just so that we don't have to require an actual
+    # dataset for some tests
+    class MockHierarchy(GridIndex):
+        pass
+
+    class MockDataset(Dataset):
+        _index_class = MockHierarchy
+
+        def _parse_parameter_file(self, *args, **kwargs):
+            self.current_time = -1.0
+            self.cosmological_simulation = 0
+
+        def _set_code_unit_attributes(self, *args, **kwargs):
+            self.length_unit = self.quan(1, "m")
+            self.mass_unit = self.quan(1, "kg")
+            self.time_unit = self.quan(1, "s")
+
+        @classmethod
+        def _is_valid(cls, *args, **kwargs):
+            return True
+
+    yield
+
+    # teardown to avoid possible breakage in following tests
+    output_type_registry.pop("MockDataset")
+
+
+@pytest.mark.usefixtures("catchall_dataset_class")
+def test_depr_load_keyword(tmp_path):
+    with pytest.raises(
+        VisibleDeprecationWarning,
+        match=r"Using the 'fn' argument as keyword \(on position 0\) is deprecated\.",
+    ):
+        load(fn=tmp_path)