Repository: astropy/pyvo

Issue:
MAINT: making all optional arguments keyword only
This would point towards the direction of making the API a bit more strict to require being explicit.

https://peps.python.org/pep-3102/

Discussion:
tomdonaldson: I like these call signatures as they will make the method calls much more clear and explicit.  However isn't this a rather significant API change?  Like in the test failures, won't existing user code fail without the keyword specifications?
bsipocz: @tomdonaldson - I don't see any failures in astroquery, but will run the navo notebooks, too. And yes, it's a significant API change, but thus I would like to get them in sooner rather than later (assuming we decide we would want this).
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/astropy/pyvo/pull/507?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=astropy) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`c49a408`)](https://app.codecov.io/gh/astropy/pyvo/commit/c49a408116491a1dd73a44c6aa9b9f8651f2faf8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=astropy) 80.28% compared to head [(`e021bbc`)](https://app.codecov.io/gh/astropy/pyvo/pull/507?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=astropy) 80.28%.


<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main     #507   +/-   ##
=======================================
  Coverage   80.28%   80.28%           
=======================================
  Files          52       52           
  Lines        6152     6152           
=======================================
  Hits         4939     4939           
  Misses       1213     1213           
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/astropy/pyvo/pull/507?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=astropy).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=astropy).

bsipocz: @tomdonaldson - I don't see any errors due to this in the navo repo either (the is one timeout, but it should be unrelated, and hopefully there isn't anything lurking in the rest of that notebook)
msdemlei: On Fri, Dec 15, 2023 at 02:57:29PM -0800, Brigitta Sipőcz wrote:
> @tomdonaldson - I don't see any errors due to this in the navo repo
> either (the is one timeout, but it should be unrelated, and
> hopefully there isn't anything lurking in the rest of that
> notebook)

Hmyeah, but I'm sure there's a lot of user code that will break.

Now, I agree that it's probably worthwhile to purse this API cleanup,
and we should certainly merge the fixes to the tests and to
documentation so people don't copy bad practices.

As to merging the signature changes...  ah well, if there was a way
to sanely just raise deprecation warnings for two or three minor
versions, I'd say that'd be fine.

But I don't think there is, and so perhaps we should briefly stop to
think whether that's an indication we ought to do a major version
change?  And if so, what else we would change?  For instance, there's
the currently very cavalier handling of XML namespaces that probably
can only be fixed by slightly breaking the API, and perhaps we would
be a little less property-happy...?  I'm sure there are many other
places that could be improved (datalink processing services come to
mind).

Disclaimer: I'm afraid I can't pledge *significant* resources for
such an overhaul, but I thought when we touch a large part of the
API, perhaps now is the time to think about it.

bsipocz: So @msdemlei, would you say hold this up and release v1.5 without this PR?

As you see, nothing in fact needed to be changed in our own documentation, nothing got caught up in astroquery, nor in the navo notebooks, so while this could indeed cause failures, in practice I would hope that people are sanely use the API rather than feeding it a lot of positional arguments.

(I'm also very much in favour of cleaning up or simplifying the API, but I also don't see that I will be able to assign significant resources for it. If I run into something bothersome, I'll try to clean it, but it all will be incremental).
msdemlei: On Mon, Dec 18, 2023 at 10:49:28PM -0800, Brigitta Sipőcz wrote:
> So @msdemlei, would you say hold this up and release v1.5 without this PR?

My instinct (after having broken too many things on the assumption
"Aw, nobody will have done that") would be to release 1.5 and then
merge the signature changes early in 1.6; I'd hope we'll get a bit
more experience with the ramifications out there in the wild in the
1.6 cycle.

> As you see, nothing in fact needed to be changed in our own
> documentation, nothing got caught up in astroquery, nor in the navo
> notebooks, so while this could indeed cause failures, in practice I

Sure, but these are all rather controlled and civilised environments.
Out there, it's folks who will type just about anything to get the
job done...

As long as our documentation and tests show the right patterns, I'd
say we've done enough for 1.5, and if we've had the whole run-up to
1.6 for people to notice that their code will break, at least we can
then say "you could have spoken up earlier".

bsipocz: > As long as our documentation and tests show the right patterns, I'd
say we've done enough for 1.5, and if we've had the whole run-up to
1.6 for people to notice that their code will break, at least we can
then say "you could have spoken up earlier".

OK. I'll run a through this to see whether any of these functions are brand new in 1.5, and if yes, than pull the updates for those, but leave the rest, that actually changes existing API alone. And then do the release.
bsipocz: I haven't noticed anything that could go in the release without changing the released API (compared to 1.4.2), so I'll separate out the test updates and merge that one, but leave the rest alone.

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index c54b7be..c62162a 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -4,6 +4,8 @@
 Enhancements and Fixes
 ----------------------
 
+- Making optional parameters keyword only throughout the public API. [#507]
+
 
 Deprecations and Removals
 -------------------------
diff --git a/pyvo/dal/adhoc.py b/pyvo/dal/adhoc.py
index fe77839..c5677ee 100644
--- a/pyvo/dal/adhoc.py
+++ b/pyvo/dal/adhoc.py
@@ -107,7 +107,7 @@ class AdhocServiceResultsMixin:
     Mixing for adhoc:service functionallity for results classes.
     """
 
-    def __init__(self, votable, url=None, session=None):
+    def __init__(self, votable, *, url=None, session=None):
         super().__init__(votable, url=url, session=session)
 
         self._adhocservices = list(
@@ -281,7 +281,7 @@ class DatalinkService(DALService, AvailabilityMixin, CapabilityMixin):
         if hasattr(self._session, 'update_from_capabilities'):
             self._session.update_from_capabilities(self.capabilities)
 
-    def run_sync(self, id, responseformat=None, **keywords):
+    def run_sync(self, id, *, responseformat=None, **keywords):
         """
         runs sync query and returns its result
 
@@ -306,7 +306,7 @@ class DatalinkService(DALService, AvailabilityMixin, CapabilityMixin):
     # alias for service discovery
     search = run_sync
 
-    def create_query(self, id, responseformat=None, **keywords):
+    def create_query(self, id, *, responseformat=None, **keywords):
         """
         create a query object that constraints can be added to and then
         executed.  The input arguments will initialize the query with the
@@ -347,7 +347,7 @@ class DatalinkQuery(DALQuery):
     allowing the caller to take greater control of the result processing.
     """
     @classmethod
-    def from_resource(cls, rows, resource, session=None, **kwargs):
+    def from_resource(cls, rows, resource, *, session=None, **kwargs):
         """
         Creates a instance from a number of records and a Datalink Resource.
 
@@ -406,7 +406,7 @@ class DatalinkQuery(DALQuery):
         return cls(accessurl, session=session, **query_params)
 
     def __init__(
-            self, baseurl, id=None, responseformat=None, session=None, **keywords):
+            self, baseurl, *, id=None, responseformat=None, session=None, **keywords):
         """
         initialize the query object with the given parameters
 
@@ -519,7 +519,7 @@ class DatalinkResults(DatalinkResultsMixin, DALResults):
         """
         return DatalinkRecord(self, index, session=self._session)
 
-    def bysemantics(self, semantics, include_narrower=True):
+    def bysemantics(self, semantics, *, include_narrower=True):
         """
         return the rows with the dataset identified by the given semantics
 
@@ -600,7 +600,7 @@ class DatalinkResults(DatalinkResultsMixin, DALResults):
                 copy_tb.resources.remove(x)
         return DatalinkResults(copy_tb)
 
-    def getdataset(self, timeout=None):
+    def getdataset(self, *, timeout=None):
         """
         return the first row with the dataset identified by semantics #this
 
@@ -675,7 +675,7 @@ class SodaRecordMixin:
         return None
 
     def processed(
-            self, circle=None, range=None, polygon=None, band=None, **kwargs):
+            self, *, circle=None, range=None, polygon=None, band=None, **kwargs):
         """
         Returns processed dataset.
 
@@ -860,7 +860,7 @@ class SodaQuery(DatalinkQuery, AxisParamMixin):
     """
 
     def __init__(
-            self, baseurl, circle=None, range=None, polygon=None, band=None,
+            self, baseurl, *, circle=None, range=None, polygon=None, band=None,
             **kwargs):
         super().__init__(baseurl, **kwargs)
 
diff --git a/pyvo/dal/mimetype.py b/pyvo/dal/mimetype.py
index 1839672..a78fb44 100644
--- a/pyvo/dal/mimetype.py
+++ b/pyvo/dal/mimetype.py
@@ -53,7 +53,7 @@ def mime2extension(mimetype, default=None):
     return ext
 
 
-def mime_object_maker(url, mimetype, session=None):
+def mime_object_maker(url, mimetype, *, session=None):
     """
     return a data object suitable for the mimetype given.
     this will either return a astropy fits object or a pyvo DALResults object,
diff --git a/pyvo/dal/query.py b/pyvo/dal/query.py
index 43dcff5..f533523 100644
--- a/pyvo/dal/query.py
+++ b/pyvo/dal/query.py
@@ -53,7 +53,7 @@ class DALService:
     endpoint.
     """
 
-    def __init__(self, baseurl, session=None):
+    def __init__(self, baseurl, *, session=None):
         """
         instantiate the service connecting it to a base URL
 
@@ -131,7 +131,7 @@ class DALQuery(dict):
 
     _ex = None
 
-    def __init__(self, baseurl, session=None, **keywords):
+    def __init__(self, baseurl, *, session=None, **keywords):
         """
         initialize the query object with a baseurl
         """
@@ -183,7 +183,7 @@ class DALQuery(dict):
         return out
 
     @stream_decode_content
-    def execute_stream(self, post=False):
+    def execute_stream(self, *, post=False):
         """
         Submit the query and return the raw response as a file stream.
 
@@ -200,7 +200,7 @@ class DALQuery(dict):
         finally:
             return response.raw
 
-    def submit(self, post=False):
+    def submit(self, *, post=False):
         """
         does the actual request
         """
@@ -215,7 +215,7 @@ class DALQuery(dict):
                                          allow_redirects=True)
         return response
 
-    def execute_votable(self, post=False):
+    def execute_votable(self, *, post=False):
         """
         Submit the query and return the results as an AstroPy votable instance.
         As this is the level where qualified error messages are available,
@@ -275,7 +275,7 @@ class DALResults:
         return session.get(result_url, stream=True).raw
 
     @classmethod
-    def from_result_url(cls, result_url, session=None):
+    def from_result_url(cls, result_url, *, session=None):
         """
         Create a result object from a url.
 
@@ -287,7 +287,7 @@ class DALResults:
             url=result_url,
             session=session)
 
-    def __init__(self, votable, url=None, session=None):
+    def __init__(self, votable, *, url=None, session=None):
         """
         initialize the cursor.  This constructor is not typically called
         by directly applications; rather an instance is obtained from calling
@@ -626,7 +626,7 @@ class DALResults:
             yield out
             pos += 1
 
-    def broadcast_samp(self, client_name=None):
+    def broadcast_samp(self, *, client_name=None):
         """
         Broadcast the table to ``client_name`` via SAMP
         """
@@ -652,7 +652,7 @@ class Record(Mapping):
     additional functions for access to service type-specific data.
     """
 
-    def __init__(self, results, index, session=None):
+    def __init__(self, results, index, *, session=None):
         self._results = results
         self._index = index
         self._session = use_session(session)
@@ -790,7 +790,7 @@ class Record(Mapping):
 
         return response.raw
 
-    def cachedataset(self, filename=None, dir=".", timeout=None, bufsize=None):
+    def cachedataset(self, *, filename=None, dir=".", timeout=None, bufsize=None):
         """
         retrieve the dataset described by this record and write it out to
         a file with the given name.  If the file already exists, it will be
@@ -844,7 +844,7 @@ class Record(Mapping):
 
     _dsname_no = 0  # used by make_dataset_filename
 
-    def make_dataset_filename(self, dir=".", base=None, ext=None):
+    def make_dataset_filename(self, *, dir=".", base=None, ext=None):
         """
         create a viable pathname in a given directory for saving the dataset
         available via getdataset().  The pathname that is returned is
@@ -916,7 +916,7 @@ class Record(Mapping):
         # abstract; specialized for the different service types
         return "dataset"
 
-    def suggest_extension(self, default=None):
+    def suggest_extension(self, *, default=None):
         """
         returns a recommended filename extension for the dataset described
         by this record.  Typically, this would look at the column describing
diff --git a/pyvo/dal/scs.py b/pyvo/dal/scs.py
index da3f424..e629bb1 100644
--- a/pyvo/dal/scs.py
+++ b/pyvo/dal/scs.py
@@ -33,7 +33,7 @@ from .adhoc import DatalinkResultsMixin, DatalinkRecordMixin
 __all__ = ["search", "SCSService", "SCSQuery", "SCSResults", "SCSRecord"]
 
 
-def search(url, pos, radius=1.0, verbosity=2, **keywords):
+def search(url, pos, *, radius=1.0, verbosity=2, **keywords):
     """
     submit a simple Cone Search query that requests objects or observations
     whose positions fall within some distance from a search position.
@@ -87,7 +87,7 @@ class SCSService(DALService):
     a representation of a Cone Search service
     """
 
-    def __init__(self, baseurl, session=None):
+    def __init__(self, baseurl, *, session=None):
         """
         instantiate a Cone Search service
 
@@ -139,7 +139,7 @@ class SCSService(DALService):
         except AttributeError:
             return []
 
-    def search(self, pos, radius=1.0, verbosity=2, **keywords):
+    def search(self, pos, *, radius=1.0, verbosity=2, **keywords):
         """
         submit a simple Cone Search query that requests objects or observations
         whose positions fall within some distance from a search position.
@@ -186,7 +186,7 @@ class SCSService(DALService):
         """
         return self.create_query(pos=pos, radius=radius, verbosity=verbosity, **keywords).execute()
 
-    def create_query(self, pos=None, radius=None, verbosity=None, **keywords):
+    def create_query(self, pos=None, *, radius=None, verbosity=None, **keywords):
         """
         create a query object that constraints can be added to and then
         executed.  The input arguments will initialize the query with the
@@ -275,7 +275,7 @@ class SCSQuery(DALQuery):
     """
 
     def __init__(
-            self, baseurl, pos=None, radius=None, verbosity=None, session=None, **keywords):
+            self, baseurl, pos=None, *, radius=None, verbosity=None, session=None, **keywords):
         """
         initialize the query object with a baseurl and the given parameters
 
diff --git a/pyvo/dal/sia.py b/pyvo/dal/sia.py
index 1f8e67d..3f23d64 100644
--- a/pyvo/dal/sia.py
+++ b/pyvo/dal/sia.py
@@ -46,7 +46,7 @@ __all__ = ["search", "SIAService", "SIAQuery", "SIAResults", "SIARecord"]
 
 
 def search(
-        url, pos, size=1.0, format=None, intersect=None, verbosity=2,
+        url, pos, *, size=1.0, format=None, intersect=None, verbosity=2,
         **keywords):
     """
     submit a simple SIA query that requests images overlapping a given region
@@ -122,7 +122,7 @@ class SIAService(DALService):
     a representation of an SIA service
     """
 
-    def __init__(self, baseurl, session=None):
+    def __init__(self, baseurl, *, session=None):
         """
         instantiate an SIA service
 
@@ -188,7 +188,7 @@ class SIAService(DALService):
             return []
 
     def search(
-            self, pos, size=1.0, format=None, intersect=None,
+            self, pos, *, size=1.0, format=None, intersect=None,
             verbosity=2, **keywords):
         """
         submit a SIA query to this service with the given parameters.
@@ -259,7 +259,7 @@ class SIAService(DALService):
             pos=pos, size=size, format=format, intersect=intersect, verbosity=verbosity, **keywords).execute()
 
     def create_query(
-            self, pos=None, size=None, format=None, intersect=None,
+            self, pos=None, *, size=None, format=None, intersect=None,
             verbosity=None, **keywords):
         """
         create a query object that constraints can be added to and then
@@ -347,7 +347,7 @@ class SIAQuery(DALQuery):
     """
 
     def __init__(
-            self, baseurl, pos=None, size=None, format=None, intersect=None,
+            self, baseurl, pos=None, *, size=None, format=None, intersect=None,
             verbosity=None, session=None, **keywords):
         """
         initialize the query object with a baseurl and the given parameters
@@ -903,7 +903,7 @@ class SIARecord(SodaRecordMixin, DatalinkRecordMixin, Record):
             out = re.sub(r'\s+', '_', out.strip())
         return out
 
-    def suggest_extension(self, default=None):
+    def suggest_extension(self, *, default=None):
         """
         returns a recommended filename extension for the dataset described
         by this record.  Typically, this would look at the column describing
@@ -911,7 +911,7 @@ class SIARecord(SodaRecordMixin, DatalinkRecordMixin, Record):
         """
         return mime2extension(self.format, default)
 
-    def broadcast_samp(self, client_name=None):
+    def broadcast_samp(self, *, client_name=None):
         """
         Broadcast the image to ``client_name`` via SAMP
         """
diff --git a/pyvo/dal/sia2.py b/pyvo/dal/sia2.py
index 86ddbdc..3acbacb 100644
--- a/pyvo/dal/sia2.py
+++ b/pyvo/dal/sia2.py
@@ -102,7 +102,7 @@ def __getattr__(name):
     raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
 
 
-def search(url, pos=None, band=None, time=None, pol=None,
+def search(url, pos=None, *, band=None, time=None, pol=None,
            field_of_view=None, spatial_resolution=None,
            spectral_resolving_power=None, exptime=None,
            timeres=None, publisher_did=None, facility=None, collection=None,
@@ -156,7 +156,7 @@ class SIA2Service(DALService, AvailabilityMixin, CapabilityMixin):
     generally not notice that, though.
     """
 
-    def __init__(self, baseurl, session=None, check_baseurl=True):
+    def __init__(self, baseurl, *, session=None, check_baseurl=True):
         """
         instantiate an SIA2 service
 
@@ -198,7 +198,7 @@ class SIA2Service(DALService, AvailabilityMixin, CapabilityMixin):
                         continue
                     break
 
-    def search(self, pos=None, band=None, time=None, pol=None,
+    def search(self, pos=None, *, band=None, time=None, pol=None,
                field_of_view=None, spatial_resolution=None,
                spectral_resolving_power=None, exptime=None,
                timeres=None, publisher_did=None, facility=None, collection=None,
@@ -232,7 +232,7 @@ class SIA2Query(DALQuery, AxisParamMixin):
     used to interact with SIA2 services.
     """
 
-    def __init__(self, url, pos=None, band=None, time=None, pol=None,
+    def __init__(self, url, pos=None, *, band=None, time=None, pol=None,
                  field_of_view=None, spatial_resolution=None,
                  spectral_resolving_power=None, exptime=None,
                  timeres=None, publisher_did=None,
diff --git a/pyvo/dal/sla.py b/pyvo/dal/sla.py
index 25ef7d8..d27c11d 100644
--- a/pyvo/dal/sla.py
+++ b/pyvo/dal/sla.py
@@ -71,7 +71,7 @@ class SLAService(DALService):
     """
     a representation of an spectral line catalog (SLA) service
     """
-    def __init__(self, baseurl, session=None):
+    def __init__(self, baseurl, *, session=None):
         """
         instantiate an SLA service
 
@@ -163,7 +163,7 @@ class SLAService(DALService):
         """
         return self.create_query(wavelength, **keywords).execute()
 
-    def create_query(self, wavelength=None, request="queryData", **keywords):
+    def create_query(self, wavelength=None, *, request="queryData", **keywords):
         """
         create a query object that constraints can be added to and then
         executed.  The input arguments will initialize the query with the
@@ -242,8 +242,7 @@ class SLAQuery(DALQuery):
     """
 
     def __init__(
-            self, baseurl, wavelength=None, request="queryData", session=None,
-            **keywords):
+            self, baseurl, wavelength=None, *, request="queryData", session=None):
         """
         initialize the query object with a baseurl and the given parameters
 
@@ -256,11 +255,6 @@ class SLAQuery(DALQuery):
             assuming meters if unit is not specified.
         session : object
            optional session to use for network requests
-        **keywords :
-            additional parameters can be given via arbitrary
-            case insensitive keyword arguments. Where there is overlap
-            with the parameters set by the other arguments to
-            this function, these keywords will override.
         """
         super().__init__(baseurl, session=session)
 
diff --git a/pyvo/dal/ssa.py b/pyvo/dal/ssa.py
index be31a51..88f867c 100644
--- a/pyvo/dal/ssa.py
+++ b/pyvo/dal/ssa.py
@@ -50,7 +50,7 @@ __all__ = ["search", "SSAService", "SSAQuery", "SSAResults", "SSARecord"]
 
 
 def search(
-        baseurl, pos=None, diameter=None, band=None, time=None, format=None,
+        baseurl, pos=None, *, diameter=None, band=None, time=None, format=None,
         **keywords):
     """
     submit a simple SSA query that requests spectra overlapping a given region
@@ -110,7 +110,7 @@ class SSAService(DALService):
     a representation of an SSA service
     """
 
-    def __init__(self, baseurl, session=None):
+    def __init__(self, baseurl, *, session=None):
         """
         instantiate an SSA service
 
@@ -161,7 +161,7 @@ class SSAService(DALService):
             return []
 
     def search(
-            self, pos=None, diameter=None, band=None, time=None, format=None,
+            self, pos=None, *, diameter=None, band=None, time=None, format=None,
             **keywords):
         """
         submit a SSA query to this service with the given constraints.
@@ -214,7 +214,7 @@ class SSAService(DALService):
             pos=pos, diameter=diameter, band=band, time=time, format=format, **keywords).execute()
 
     def create_query(
-            self, pos=None, diameter=None, band=None, time=None, format=None,
+            self, pos=None, *, diameter=None, band=None, time=None, format=None,
             request="queryData", **keywords):
         """
         create a query object that constraints can be added to and then
@@ -309,7 +309,7 @@ class SSAQuery(DALQuery):
     """
 
     def __init__(
-            self, baseurl, pos=None, diameter=None, band=None, time=None,
+            self, baseurl, pos=None, *, diameter=None, band=None, time=None,
             format=None, request="queryData", session=None, **keywords):
         """
         initialize the query object with a baseurl and the given parameters
@@ -755,7 +755,7 @@ class SSARecord(SodaRecordMixin, DatalinkRecordMixin, Record):
             out = re.sub(r'\s+', '_', out.strip())
         return out
 
-    def suggest_extension(self, default=None):
+    def suggest_extension(self, *, default=None):
         """
         returns a recommended filename extension for the dataset described
         by this record.  Typically, this would look at the column describing
@@ -763,7 +763,7 @@ class SSARecord(SodaRecordMixin, DatalinkRecordMixin, Record):
         """
         return mime2extension(self.format, default)
 
-    def broadcast_samp(self, client_name=None):
+    def broadcast_samp(self, *, client_name=None):
         """
         Broadcast the spectrum to ``client_name`` via SAMP
         """
diff --git a/pyvo/dal/tap.py b/pyvo/dal/tap.py
index 248d037..ce685c4 100644
--- a/pyvo/dal/tap.py
+++ b/pyvo/dal/tap.py
@@ -63,7 +63,7 @@ def escape(term):
     return str(term).replace("'", "''")
 
 
-def search(url, query, language="ADQL", maxrec=None, uploads=None, **keywords):
+def search(url, query, *, language="ADQL", maxrec=None, uploads=None, **keywords):
     """
     submit a Table Access query that returns rows matching the criteria given.
 
@@ -107,7 +107,7 @@ class TAPService(DALService, AvailabilityMixin, CapabilityMixin):
     _tables = None
     _examples = None
 
-    def __init__(self, baseurl, session=None):
+    def __init__(self, baseurl, *, session=None):
         """
         instantiate a Table Access Protocol service
 
@@ -247,7 +247,7 @@ class TAPService(DALService, AvailabilityMixin, CapabilityMixin):
         return self.get_tap_capability().uploadmethods
 
     def run_sync(
-            self, query, language="ADQL", maxrec=None, uploads=None,
+            self, query, *, language="ADQL", maxrec=None, uploads=None,
             **keywords):
         """
         runs sync query and returns its result
@@ -281,7 +281,7 @@ class TAPService(DALService, AvailabilityMixin, CapabilityMixin):
     search = run_sync
 
     def run_async(
-            self, query, language="ADQL", maxrec=None, uploads=None,
+            self, query, *, language="ADQL", maxrec=None, uploads=None,
             **keywords):
         """
         runs async query and returns its result
@@ -328,7 +328,7 @@ class TAPService(DALService, AvailabilityMixin, CapabilityMixin):
         return result
 
     def submit_job(
-            self, query, language="ADQL", maxrec=None, uploads=None,
+            self, query, *, language="ADQL", maxrec=None, uploads=None,
             **keywords):
         """
         submit a async query without starting it and returns a AsyncTAPJob
@@ -360,7 +360,7 @@ class TAPService(DALService, AvailabilityMixin, CapabilityMixin):
             session=self._session, **keywords)
 
     def create_query(
-            self, query=None, mode="sync", language="ADQL", maxrec=None,
+            self, query=None, *, mode="sync", language="ADQL", maxrec=None,
             uploads=None, **keywords):
         """
         create a query object that constraints can be added to and then
@@ -409,7 +409,7 @@ class TAPService(DALService, AvailabilityMixin, CapabilityMixin):
                                     decode_content=True)
         return uws.parse_job(response.raw.read)
 
-    def get_job_list(self, phases=None, after=None, last=None,
+    def get_job_list(self, *, phases=None, after=None, last=None,
                      short_description=True):
         """
         lists jobs that the caller can see in the current security context.
@@ -485,7 +485,7 @@ class TAPService(DALService, AvailabilityMixin, CapabilityMixin):
             print()
 
     @prototype_feature('cadc-tb-upload')
-    def create_table(self, name, definition, format='VOSITable'):
+    def create_table(self, name, definition, *, format='VOSITable'):
         """
         Creates a table in the catalog service.
 
@@ -534,7 +534,7 @@ class TAPService(DALService, AvailabilityMixin, CapabilityMixin):
         response.raise_for_status()
 
     @prototype_feature('cadc-tb-upload')
-    def load_table(self, name, source, format='tsv'):
+    def load_table(self, name, source, *, format='tsv'):
         """
         Loads content to a table
 
@@ -565,7 +565,7 @@ class TAPService(DALService, AvailabilityMixin, CapabilityMixin):
         response.raise_for_status()
 
     @prototype_feature('cadc-tb-upload')
-    def create_index(self, table_name, column_name, unique=False):
+    def create_index(self, table_name, column_name, *, unique=False):
         """
         Creates a table index in the catalog service.
 
@@ -615,7 +615,7 @@ class AsyncTAPJob:
 
     @classmethod
     def create(
-            cls, baseurl, query, language="ADQL", maxrec=None, uploads=None,
+            cls, baseurl, query, *, language="ADQL", maxrec=None, uploads=None,
             session=None, **keywords):
         """
         creates a async tap job on the server under `baseurl`
@@ -643,7 +643,7 @@ class AsyncTAPJob:
         job = cls(response.url, session=session)
         return job
 
-    def __init__(self, url, session=None):
+    def __init__(self, url, *, session=None):
         """
         initialize the job object with the given url and fetch remote values
 
@@ -920,7 +920,7 @@ class AsyncTAPJob:
 
         return self
 
-    def wait(self, phases=None, timeout=600.):
+    def wait(self, *, phases=None, timeout=600.):
         """
         waits for the job to reach the given phases.
 
@@ -1028,7 +1028,7 @@ class TAPQuery(DALQuery):
     """
 
     def __init__(
-            self, baseurl, query, mode="sync", language="ADQL", maxrec=None,
+            self, baseurl, query, *, mode="sync", language="ADQL", maxrec=None,
             uploads=None, session=None, **keywords):
         """
         initialize the query object with the given parameters
@@ -1079,7 +1079,7 @@ class TAPQuery(DALQuery):
         """
         return '{baseurl}/{mode}'.format(baseurl=self.baseurl, mode=self._mode)
 
-    def execute_stream(self, post=False):
+    def execute_stream(self, *, post=False):
         """
         submit the query and return the raw VOTable XML as a file stream
 
@@ -1114,7 +1114,7 @@ class TAPQuery(DALQuery):
         """
         return TAPResults(self.execute_votable(), url=self.queryurl, session=self._session)
 
-    def submit(self, post=False):
+    def submit(self, *, post=False):
         """
         Does the request part of the TAP query.
         This function is separated from response parsing because async queries
diff --git a/pyvo/dal/vosi.py b/pyvo/dal/vosi.py
index 4ee6208..0cdbd99 100644
--- a/pyvo/dal/vosi.py
+++ b/pyvo/dal/vosi.py
@@ -145,7 +145,7 @@ class VOSITables:
     Access to table names is like accessing dictionary keys. using iterator
     syntax or `keys()`
     """
-    def __init__(self, vosi_tables, endpoint_url, session=None):
+    def __init__(self, vosi_tables, endpoint_url, *, session=None):
         self._vosi_tables = vosi_tables
         self._endpoint_url = endpoint_url
         self._cache = {}
diff --git a/pyvo/io/vosi/endpoint.py b/pyvo/io/vosi/endpoint.py
index 4d87146..1245b1b 100644
--- a/pyvo/io/vosi/endpoint.py
+++ b/pyvo/io/vosi/endpoint.py
@@ -45,7 +45,7 @@ def _pedantic_settings(pedantic):
         return {'verify': 'warn'}
 
 
-def parse_tables(source, pedantic=None, filename=None,
+def parse_tables(source, *, pedantic=None, filename=None,
                  _debug_python_based_parser=False):
     """
     Parses a tableset xml file (or file-like object), and returns a
@@ -91,7 +91,7 @@ def parse_tables(source, pedantic=None, filename=None,
             config=config, pos=(1, 1)).parse(iterator, config)
 
 
-def parse_capabilities(source, pedantic=None, filename=None,
+def parse_capabilities(source, *, pedantic=None, filename=None,
                        _debug_python_based_parser=False):
     """
     Parses a capabilities xml file (or file-like object), and returns a
@@ -137,7 +137,7 @@ def parse_capabilities(source, pedantic=None, filename=None,
             config=config, pos=(1, 1)).parse(iterator, config)
 
 
-def parse_availability(source, pedantic=None, filename=None,
+def parse_availability(source, *, pedantic=None, filename=None,
                        _debug_python_based_parser=False):
     """
     Parses a availability xml file (or file-like object), and returns a
@@ -190,7 +190,7 @@ class TablesFile(Element):
     name, documented below.
     """
 
-    def __init__(self, config=None, pos=None, version="1.1"):
+    def __init__(self, *, config=None, pos=None, version="1.1"):
         Element.__init__(self, config, pos)
 
         self._tableset = None
@@ -320,7 +320,7 @@ class CapabilitiesFile(Element, HomogeneousList):
     The keyword arguments correspond to setting members of the same
     name, documented below.
     """
-    def __init__(self, config=None, pos=None, _name='capabilities', **kwargs):
+    def __init__(self, *, config=None, pos=None, _name='capabilities', **kwargs):
         Element.__init__(self, config=config, pos=pos, **kwargs)
         HomogeneousList.__init__(self, vr.Capability)
 
diff --git a/pyvo/registry/regtap.py b/pyvo/registry/regtap.py
index 8e8c801..2da05f6 100644
--- a/pyvo/registry/regtap.py
+++ b/pyvo/registry/regtap.py
@@ -384,11 +384,11 @@ class Interface:
         "ivo://ivoa.net/std/sla": sla.SLAService,
         "ivo://ivoa.net/std/tap": tap.TAPService}
 
-    def __init__(self, access_url, standard_id, intf_type, intf_role):
+    def __init__(self, access_url, *, standard_id=None, intf_type=None, intf_role=None):
         self.access_url = access_url
-        self.standard_id = standard_id or None
-        self.type = intf_type or None
-        self.role = intf_role or None
+        self.standard_id = standard_id
+        self.type = intf_type
+        self.role = intf_role
         self.is_standard = self.role == "std"
 
         if self.standard_id is not None:
@@ -490,7 +490,7 @@ class RegistryResource(dalq.Record):
             "intf_roles"),
         "alt_identifier"]
 
-    def __init__(self, results, index, session=None):
+    def __init__(self, results, index, *, session=None):
         dalq.Record.__init__(self, results, index, session=session)
 
         self._mapping["access_urls"
@@ -881,7 +881,7 @@ class RegistryResource(dalq.Record):
             raise dalq.DALServiceError(
                 f"Resource {self.ivoid} is not a searchable service")
 
-    def describe(self, verbose=False, width=78, file=None):
+    def describe(self, *, verbose=False, width=78, file=None):
         """
         Print a summary description of this resource.
 
@@ -1001,7 +1001,7 @@ class RegistryResource(dalq.Record):
 
         return res
 
-    def get_tables(self, table_limit=20):
+    def get_tables(self, *, table_limit=20):
         """
         return the structure of the tables underlying the service.
 


Test patch:
diff --git a/pyvo/registry/tests/test_regtap.py b/pyvo/registry/tests/test_regtap.py
index 1f1cdba..2a936fa 100644
--- a/pyvo/registry/tests/test_regtap.py
+++ b/pyvo/registry/tests/test_regtap.py
@@ -204,7 +204,7 @@ def _flash_service(multi_interface_fixture):
 
 class TestInterfaceClass:
     def test_basic(self):
-        intf = regtap.Interface("http://example.org", "", "", "")
+        intf = regtap.Interface("http://example.org")
         assert intf.access_url == "http://example.org"
         assert intf.standard_id is None
         assert intf.type is None