[arvados] updated: 2.7.0-33-gd53b38d1fa

git repository hosting git at public.arvados.org
Tue Dec 5 20:24:02 UTC 2023


Summary of changes:
 doc/user/topics/arv-copy.html.textile.liquid       |    4 +-
 go.mod                                             |   31 +-
 go.sum                                             |   96 +-
 sdk/go/arvados/client.go                           |   24 +-
 sdk/go/arvados/client_test.go                      |   14 +
 sdk/go/auth/auth.go                                |   12 +-
 sdk/go/auth/handlers_test.go                       |   36 +-
 sdk/python/arvados/__init__.py                     |   38 +-
 sdk/python/arvados/api.py                          |   77 +-
 sdk/python/arvados/collection.py                   | 2213 +++++++++++---------
 sdk/python/arvados/retry.py                        |   60 +-
 sdk/python/arvados/safeapi.py                      |   45 +-
 sdk/python/arvados/util.py                         |  502 +++--
 sdk/python/discovery2pydoc.py                      |   89 +-
 sdk/python/tests/run_test_server.py                |   10 +-
 services/api/app/models/arvados_model.rb           |   45 +-
 .../arvados/v1/collections_controller_test.rb      |    4 +-
 .../arvados/v1/groups_controller_test.rb           |    4 +-
 services/api/test/unit/container_request_test.rb   |    6 +-
 services/keep-web/cache.go                         |    2 +-
 services/keep-web/handler_test.go                  |   24 +-
 21 files changed, 2014 insertions(+), 1322 deletions(-)

       via  d53b38d1fa91725b10c7359278e52a7f2f6a15db (commit)
       via  8674a32277bc11484d7aa9b16649869256bbdc17 (commit)
       via  39bed89acebb3cff63831f36b0915378480f4ae5 (commit)
       via  7a8c6b58534518990d05d895cb33d3f74553755d (commit)
       via  a24ea36c4b5175229179040e842760fd11ba7b7a (commit)
       via  b9fbbeca2c603820815cc82293edb795f1e95127 (commit)
       via  1fbd0ad57137ef23a6ec957746c05127ab8cc8c5 (commit)
       via  3e5c9648ad4e9d5d9c320558b30a226a4702343c (commit)
       via  29aeb9634b700eec2e5866782f08a09450a20dfd (commit)
      from  b198eda19443c899602fb11d800a999ccd3521a8 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.


commit d53b38d1fa91725b10c7359278e52a7f2f6a15db
Author: Lucas Di Pentima <lucas.dipentima at curii.com>
Date:   Tue Dec 5 12:12:18 2023 -0300

    Merge branch '21262-dependency-upgrades'. Closes #21262
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima at curii.com>

diff --git a/go.mod b/go.mod
index d7bb5768eb..218e2ddde8 100644
--- a/go.mod
+++ b/go.mod
@@ -15,7 +15,7 @@ require (
 	github.com/coreos/go-oidc/v3 v3.5.0
 	github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e
 	github.com/creack/pty v1.1.18
-	github.com/docker/docker v24.0.5+incompatible
+	github.com/docker/docker v24.0.7+incompatible
 	github.com/dustin/go-humanize v1.0.0
 	github.com/fsnotify/fsnotify v1.4.9
 	github.com/ghodss/yaml v1.0.0
@@ -37,11 +37,11 @@ require (
 	github.com/prometheus/client_model v0.3.0
 	github.com/prometheus/common v0.39.0
 	github.com/sirupsen/logrus v1.8.1
-	golang.org/x/crypto v0.5.0
-	golang.org/x/net v0.9.0
-	golang.org/x/oauth2 v0.7.0
-	golang.org/x/sys v0.7.0
-	google.golang.org/api v0.114.0
+	golang.org/x/crypto v0.16.0
+	golang.org/x/net v0.19.0
+	golang.org/x/oauth2 v0.11.0
+	golang.org/x/sys v0.15.0
+	google.golang.org/api v0.126.0
 	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
 	gopkg.in/square/go-jose.v2 v2.5.1
 	gopkg.in/src-d/go-billy.v4 v4.0.1
@@ -50,7 +50,7 @@ require (
 )
 
 require (
-	cloud.google.com/go/compute v1.19.1 // indirect
+	cloud.google.com/go/compute v1.23.0 // indirect
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
 	github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
 	github.com/Azure/go-autorest v14.2.0+incompatible // indirect
@@ -74,13 +74,14 @@ require (
 	github.com/docker/go-units v0.4.0 // indirect
 	github.com/gliderlabs/ssh v0.2.2 // indirect
 	github.com/go-asn1-ber/asn1-ber v1.4.1 // indirect
-	github.com/go-jose/go-jose/v3 v3.0.0 // indirect
+	github.com/go-jose/go-jose/v3 v3.0.1 // indirect
 	github.com/golang-jwt/jwt/v4 v4.1.0 // indirect
-	github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
+	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/protobuf v1.5.3 // indirect
-	github.com/google/uuid v1.3.0 // indirect
+	github.com/google/s2a-go v0.1.4 // indirect
+	github.com/google/uuid v1.3.1 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
-	github.com/googleapis/gax-go/v2 v2.7.1 // indirect
+	github.com/googleapis/gax-go/v2 v2.11.0 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
@@ -103,13 +104,13 @@ require (
 	github.com/src-d/gcfg v1.3.0 // indirect
 	github.com/xanzy/ssh-agent v0.1.0 // indirect
 	go.opencensus.io v0.24.0 // indirect
-	golang.org/x/text v0.9.0 // indirect
+	golang.org/x/text v0.14.0 // indirect
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
 	golang.org/x/tools v0.6.0 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
-	google.golang.org/grpc v1.56.1 // indirect
-	google.golang.org/protobuf v1.30.0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect
+	google.golang.org/grpc v1.59.0 // indirect
+	google.golang.org/protobuf v1.31.0 // indirect
 	gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect
 	gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
diff --git a/go.sum b/go.sum
index 7f71b206d2..31ddd88621 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,10 @@
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys=
-cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY=
-cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
+cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
 cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
 cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
 cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
-cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
 github.com/Azure/azure-sdk-for-go v45.1.0+incompatible h1:kxtaPD8n2z5Za+9e3sKsYG2IX6PG2R6VXtgS7gAbh3A=
 github.com/Azure/azure-sdk-for-go v45.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
@@ -44,6 +43,7 @@ github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBb
 github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs=
 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
+github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/arvados/cgofuse v1.2.0-arvados1 h1:4Q4vRJ4hbTCcI4gGEaa6hqwj3rqlUuzeFQkfoEA2HqE=
 github.com/arvados/cgofuse v1.2.0-arvados1/go.mod h1:79WFV98hrkRHK9XPhh2IGGOwpFSjocsWubgxAs2KhRc=
 github.com/arvados/goamz v0.0.0-20190905141525-1bba09f407ef h1:cl7DIRbiAYNqaVxg3CZY8qfZoBOKrj06H/x9SPGaxas=
@@ -63,10 +63,16 @@ github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx2
 github.com/bradleypeabody/godap v0.0.0-20170216002349-c249933bc092 h1:0Di2onNnlN5PAyWPbqlPyN45eOQ+QW/J9eqLynt4IV4=
 github.com/bradleypeabody/godap v0.0.0-20170216002349-c249933bc092/go.mod h1:8IzBjZCRSnsvM6MJMG8HNNtnzMl48H22rbJL2kRUJ0Y=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
+github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/coreos/go-oidc/v3 v3.5.0 h1:VxKtbccHZxs8juq7RdJntSqtXFtde9YpNpGn0yqgEHw=
 github.com/coreos/go-oidc/v3 v3.5.0/go.mod h1:ecXRtV4romGPeO6ieExAsUK9cb/3fp9hXNz1tlv8PIM=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e h1:Wf6HqHfScWJN9/ZjdUKyjop4mf3Qdd+1TvvltAvM3m8=
@@ -83,8 +89,8 @@ github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
 github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
 github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
 github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker v24.0.5+incompatible h1:WmgcE4fxyI6EEXxBRxsHnZXrO1pQ3smi0k/jho4HLeY=
-github.com/docker/docker v24.0.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
+github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/go-connections v0.3.0 h1:3lOnM9cSzgGwx8VfK/NGOW5fLQ0GjIlCkaktF+n1M6o=
 github.com/docker/go-connections v0.3.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
 github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
@@ -94,6 +100,8 @@ github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25Kn
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
@@ -104,8 +112,9 @@ github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
 github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
 github.com/go-asn1-ber/asn1-ber v1.4.1 h1:qP/QDxOtmMoJVgXHCXNzDpA0+wkgYB2x5QoLMVOciyw=
 github.com/go-asn1-ber/asn1-ber v1.4.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
-github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
 github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
+github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
+github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
 github.com/go-ldap/ldap v3.0.3+incompatible h1:HTeSZO8hWMS1Rgb2Ziku6b8a7qRIZZMHjsvuZyatzwk=
 github.com/go-ldap/ldap v3.0.3+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
@@ -117,12 +126,14 @@ github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzw
 github.com/golang-jwt/jwt/v4 v4.1.0 h1:XUgk2Ex5veyVFVeLm0xhusUTQybEbexJXrvPNOKkSY0=
 github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
 github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@@ -130,6 +141,7 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
 github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
 github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
 github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
@@ -144,17 +156,20 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc=
+github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
-github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
+github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
 github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
-github.com/googleapis/gax-go/v2 v2.7.1 h1:gF4c0zjUP2H/s/hEGyLA3I0fA2ZWjzYiONAD6cvPr8A=
-github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
+github.com/googleapis/gax-go/v2 v2.11.0 h1:9V9PWXEsWnPpQhu/PeQIkS4eGzMlTLGgt80cUUI8Ki4=
+github.com/googleapis/gax-go/v2 v2.11.0/go.mod h1:DxmR61SGKkGLa2xigwuZIQpkCI2S5iydzRfb3peWZJI=
 github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
 github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
 github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
@@ -228,6 +243,7 @@ github.com/prometheus/common v0.39.0 h1:oOyhkDq05hPZKItWVBkJ6g6AtGxi+fy7F4JvUV8u
 github.com/prometheus/common v0.39.0/go.mod h1:6XBZ7lYdLCbkAVhwRsWTZn+IN5AB9F/NXd5w0BbEX0Y=
 github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
 github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
+github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
 github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
 github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
 github.com/satori/go.uuid v1.2.1-0.20180103174451-36e9d2ebbde5 h1:Jw7W4WMfQDxsXvfeFSaS2cHlY7bAF4MGrgnbd0+Uo78=
@@ -249,6 +265,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -262,14 +279,16 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
 go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
+go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
-golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
+golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
+golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -279,6 +298,7 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190310074541-c10a0554eabf/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -287,19 +307,22 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
 golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
-golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
-golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
+golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
+golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk=
-golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g=
-golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
+golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU=
+golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -307,36 +330,41 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190310054646-10058d7d4faa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
-golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
-golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ=
+golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s=
 golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -356,24 +384,30 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.114.0 h1:1xQPji6cO2E2vLiI+C/XiFAnsn1WV3mjaEwGLhi3grE=
-google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg=
+google.golang.org/api v0.126.0 h1:q4GJq+cAdMAC7XP7njvQ4tvohGLiSlytuL4BQxbIZ+o=
+google.golang.org/api v0.126.0/go.mod h1:mBwVAtz+87bEN6CbA1GtZPDOqY2R5ONPqJeIlvyo4Aw=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
 google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
-google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
+google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d h1:VBu5YqKPv6XiJ199exd8Br+Aetz+o08F+PLMnwJQHAY=
+google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d h1:DoPTO70H+bcDXcd39vOqb2viZxgqeBeSGtZ55yZU4/Q=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
 google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.56.1 h1:z0dNfjIl0VpaZ9iSVjA6daGatAYwPGstTjt5vkRMFkQ=
-google.golang.org/grpc v1.56.1/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s=
+google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
+google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
+google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -386,8 +420,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
-google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
-google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM=
 gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

commit 8674a32277bc11484d7aa9b16649869256bbdc17
Author: Tom Clegg <tom at curii.com>
Date:   Mon Dec 4 14:35:36 2023 -0500

    Merge branch '21217-invalid-auth-header'
    
    refs #21217
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curii.com>

diff --git a/sdk/go/arvados/client.go b/sdk/go/arvados/client.go
index 735a44d24c..c2f6361334 100644
--- a/sdk/go/arvados/client.go
+++ b/sdk/go/arvados/client.go
@@ -238,6 +238,8 @@ var reqIDGen = httpserver.IDGenerator{Prefix: "req-"}
 
 var nopCancelFunc context.CancelFunc = func() {}
 
+var reqErrorRe = regexp.MustCompile(`net/http: invalid header `)
+
 // Do augments (*http.Client)Do(): adds Authorization and X-Request-Id
 // headers, delays in order to comply with rate-limiting restrictions,
 // and retries failed requests when appropriate.
@@ -274,6 +276,7 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
 	var lastResp *http.Response
 	var lastRespBody io.ReadCloser
 	var lastErr error
+	var checkRetryCalled int
 
 	rclient := retryablehttp.NewClient()
 	rclient.HTTPClient = c.httpClient()
@@ -287,11 +290,15 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
 		rclient.RetryMax = 0
 	}
 	rclient.CheckRetry = func(ctx context.Context, resp *http.Response, respErr error) (bool, error) {
+		checkRetryCalled++
 		if c.requestLimiter.Report(resp, respErr) {
 			c.last503.Store(time.Now())
 		}
 		if c.Timeout == 0 {
-			return false, err
+			return false, nil
+		}
+		if respErr != nil && reqErrorRe.MatchString(respErr.Error()) {
+			return false, nil
 		}
 		retrying, err := retryablehttp.DefaultRetryPolicy(ctx, resp, respErr)
 		if retrying {
@@ -322,7 +329,14 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
 	}
 	resp, err := rclient.Do(rreq)
 	if (errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) && (lastResp != nil || lastErr != nil) {
-		resp, err = lastResp, lastErr
+		resp = lastResp
+		err = lastErr
+		if checkRetryCalled > 0 && err != nil {
+			// Mimic retryablehttp's "giving up after X
+			// attempts" message, even if we gave up
+			// because of time rather than maxretries.
+			err = fmt.Errorf("%s %s giving up after %d attempt(s): %w", req.Method, req.URL.String(), checkRetryCalled, err)
+		}
 		if resp != nil {
 			resp.Body = lastRespBody
 		}
@@ -619,7 +633,11 @@ func (c *Client) apiURL(path string) string {
 	if scheme == "" {
 		scheme = "https"
 	}
-	return scheme + "://" + c.APIHost + "/" + path
+	// Double-slash in URLs tend to cause subtle hidden problems
+	// (e.g., they can behave differently when a load balancer is
+	// in the picture). Here we ensure exactly one "/" regardless
+	// of whether the given APIHost or path has a superfluous one.
+	return scheme + "://" + strings.TrimSuffix(c.APIHost, "/") + "/" + strings.TrimPrefix(path, "/")
 }
 
 // DiscoveryDocument is the Arvados server's description of itself.
diff --git a/sdk/go/arvados/client_test.go b/sdk/go/arvados/client_test.go
index a196003b8f..55e2f998c4 100644
--- a/sdk/go/arvados/client_test.go
+++ b/sdk/go/arvados/client_test.go
@@ -341,6 +341,20 @@ func (s *clientRetrySuite) TestNonRetryableError(c *check.C) {
 	c.Check(s.reqs, check.HasLen, 1)
 }
 
+// as of 0.7.2., retryablehttp does not recognize this as a
+// non-retryable error.
+func (s *clientRetrySuite) TestNonRetryableStdlibError(c *check.C) {
+	s.respStatus <- http.StatusOK
+	req, err := http.NewRequest(http.MethodGet, "https://"+s.client.APIHost+"/test", nil)
+	c.Assert(err, check.IsNil)
+	req.Header.Set("Good-Header", "T\033rrible header value")
+	err = s.client.DoAndDecode(&struct{}{}, req)
+	c.Check(err, check.ErrorMatches, `.*after 1 attempt.*net/http: invalid header .*`)
+	if !c.Check(s.reqs, check.HasLen, 0) {
+		c.Logf("%v", s.reqs[0])
+	}
+}
+
 func (s *clientRetrySuite) TestNonRetryableAfter503s(c *check.C) {
 	time.AfterFunc(time.Second, func() { s.respStatus <- http.StatusNotFound })
 	err := s.client.RequestAndDecode(&struct{}{}, http.MethodGet, "test", nil, nil)
diff --git a/sdk/go/auth/auth.go b/sdk/go/auth/auth.go
index f1c2e243b5..da9b4ea5b8 100644
--- a/sdk/go/auth/auth.go
+++ b/sdk/go/auth/auth.go
@@ -54,13 +54,13 @@ func (a *Credentials) LoadTokensFromHTTPRequest(r *http.Request) {
 	// Load plain token from "Authorization: OAuth2 ..." header
 	// (typically used by smart API clients)
 	if toks := strings.SplitN(r.Header.Get("Authorization"), " ", 2); len(toks) == 2 && (toks[0] == "OAuth2" || toks[0] == "Bearer") {
-		a.Tokens = append(a.Tokens, toks[1])
+		a.Tokens = append(a.Tokens, strings.TrimSpace(toks[1]))
 	}
 
 	// Load base64-encoded token from "Authorization: Basic ..."
 	// header (typically used by git via credential helper)
 	if _, password, ok := r.BasicAuth(); ok {
-		a.Tokens = append(a.Tokens, password)
+		a.Tokens = append(a.Tokens, strings.TrimSpace(password))
 	}
 
 	// Load tokens from query string. It's generally not a good
@@ -76,7 +76,9 @@ func (a *Credentials) LoadTokensFromHTTPRequest(r *http.Request) {
 	// find/report decoding errors in a suitable way.
 	qvalues, _ := url.ParseQuery(r.URL.RawQuery)
 	if val, ok := qvalues["api_token"]; ok {
-		a.Tokens = append(a.Tokens, val...)
+		for _, token := range val {
+			a.Tokens = append(a.Tokens, strings.TrimSpace(token))
+		}
 	}
 
 	a.loadTokenFromCookie(r)
@@ -94,7 +96,7 @@ func (a *Credentials) loadTokenFromCookie(r *http.Request) {
 	if err != nil {
 		return
 	}
-	a.Tokens = append(a.Tokens, string(token))
+	a.Tokens = append(a.Tokens, strings.TrimSpace(string(token)))
 }
 
 // LoadTokensFromHTTPRequestBody loads credentials from the request
@@ -111,7 +113,7 @@ func (a *Credentials) LoadTokensFromHTTPRequestBody(r *http.Request) error {
 		return err
 	}
 	if t := r.PostFormValue("api_token"); t != "" {
-		a.Tokens = append(a.Tokens, t)
+		a.Tokens = append(a.Tokens, strings.TrimSpace(t))
 	}
 	return nil
 }
diff --git a/sdk/go/auth/handlers_test.go b/sdk/go/auth/handlers_test.go
index 362aeb7f04..85ea8893a5 100644
--- a/sdk/go/auth/handlers_test.go
+++ b/sdk/go/auth/handlers_test.go
@@ -7,6 +7,8 @@ package auth
 import (
 	"net/http"
 	"net/http/httptest"
+	"net/url"
+	"strings"
 	"testing"
 
 	check "gopkg.in/check.v1"
@@ -32,9 +34,36 @@ func (s *HandlersSuite) SetUpTest(c *check.C) {
 func (s *HandlersSuite) TestLoadToken(c *check.C) {
 	handler := LoadToken(s)
 	handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/foo/bar?api_token=xyzzy", nil))
-	c.Assert(s.gotCredentials, check.NotNil)
-	c.Assert(s.gotCredentials.Tokens, check.HasLen, 1)
-	c.Check(s.gotCredentials.Tokens[0], check.Equals, "xyzzy")
+	c.Check(s.gotCredentials.Tokens, check.DeepEquals, []string{"xyzzy"})
+}
+
+// Ignore leading and trailing spaces, newlines, etc. in case a user
+// has added them inadvertently during copy/paste.
+func (s *HandlersSuite) TestTrimSpaceInQuery(c *check.C) {
+	handler := LoadToken(s)
+	handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/foo/bar?api_token=%20xyzzy%0a", nil))
+	c.Check(s.gotCredentials.Tokens, check.DeepEquals, []string{"xyzzy"})
+}
+func (s *HandlersSuite) TestTrimSpaceInPostForm(c *check.C) {
+	handler := LoadToken(s)
+	req := httptest.NewRequest("POST", "/foo/bar", strings.NewReader(url.Values{"api_token": []string{"\nxyzzy\n"}}.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	handler.ServeHTTP(httptest.NewRecorder(), req)
+	c.Check(s.gotCredentials.Tokens, check.DeepEquals, []string{"xyzzy"})
+}
+func (s *HandlersSuite) TestTrimSpaceInCookie(c *check.C) {
+	handler := LoadToken(s)
+	req := httptest.NewRequest("GET", "/foo/bar", nil)
+	req.AddCookie(&http.Cookie{Name: "arvados_api_token", Value: EncodeTokenCookie([]byte("\vxyzzy\n"))})
+	handler.ServeHTTP(httptest.NewRecorder(), req)
+	c.Check(s.gotCredentials.Tokens, check.DeepEquals, []string{"xyzzy"})
+}
+func (s *HandlersSuite) TestTrimSpaceInBasicAuth(c *check.C) {
+	handler := LoadToken(s)
+	req := httptest.NewRequest("GET", "/foo/bar", nil)
+	req.SetBasicAuth("username", "\txyzzy\n")
+	handler.ServeHTTP(httptest.NewRecorder(), req)
+	c.Check(s.gotCredentials.Tokens, check.DeepEquals, []string{"xyzzy"})
 }
 
 func (s *HandlersSuite) TestRequireLiteralTokenEmpty(c *check.C) {
@@ -76,4 +105,5 @@ func (s *HandlersSuite) TestRequireLiteralToken(c *check.C) {
 func (s *HandlersSuite) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	s.served++
 	s.gotCredentials = CredentialsFromRequest(r)
+	s.gotCredentials.LoadTokensFromHTTPRequestBody(r)
 }
diff --git a/services/keep-web/cache.go b/services/keep-web/cache.go
index 604efd29d9..df5705ed32 100644
--- a/services/keep-web/cache.go
+++ b/services/keep-web/cache.go
@@ -221,7 +221,7 @@ func (c *cache) GetSession(token string) (arvados.CustomFileSystem, *cachedSessi
 		// using the new fs).
 		sess.inuse.Lock()
 		if !sess.userLoaded || refresh {
-			err := sess.client.RequestAndDecode(&sess.user, "GET", "/arvados/v1/users/current", nil, nil)
+			err := sess.client.RequestAndDecode(&sess.user, "GET", "arvados/v1/users/current", nil, nil)
 			if he := errorWithHTTPStatus(nil); errors.As(err, &he) && he.HTTPStatus() == http.StatusForbidden {
 				// token is OK, but "get user id" api is out
 				// of scope -- use existing/expired info if
diff --git a/services/keep-web/handler_test.go b/services/keep-web/handler_test.go
index 5e81f3a01e..c14789889d 100644
--- a/services/keep-web/handler_test.go
+++ b/services/keep-web/handler_test.go
@@ -328,9 +328,10 @@ func (s *IntegrationSuite) TestVhostViaAuthzHeaderOAuth2(c *check.C) {
 	s.doVhostRequests(c, authzViaAuthzHeaderOAuth2)
 }
 func authzViaAuthzHeaderOAuth2(r *http.Request, tok string) int {
-	r.Header.Add("Authorization", "Bearer "+tok)
+	r.Header.Add("Authorization", "OAuth2 "+tok)
 	return http.StatusUnauthorized
 }
+
 func (s *IntegrationSuite) TestVhostViaAuthzHeaderBearer(c *check.C) {
 	s.doVhostRequests(c, authzViaAuthzHeaderBearer)
 }
@@ -350,6 +351,27 @@ func authzViaCookieValue(r *http.Request, tok string) int {
 	return http.StatusUnauthorized
 }
 
+func (s *IntegrationSuite) TestVhostViaHTTPBasicAuth(c *check.C) {
+	s.doVhostRequests(c, authzViaHTTPBasicAuth)
+}
+func authzViaHTTPBasicAuth(r *http.Request, tok string) int {
+	r.AddCookie(&http.Cookie{
+		Name:  "arvados_api_token",
+		Value: auth.EncodeTokenCookie([]byte(tok)),
+	})
+	return http.StatusUnauthorized
+}
+
+func (s *IntegrationSuite) TestVhostViaHTTPBasicAuthWithExtraSpaceChars(c *check.C) {
+	s.doVhostRequests(c, func(r *http.Request, tok string) int {
+		r.AddCookie(&http.Cookie{
+			Name:  "arvados_api_token",
+			Value: auth.EncodeTokenCookie([]byte(" " + tok + "\n")),
+		})
+		return http.StatusUnauthorized
+	})
+}
+
 func (s *IntegrationSuite) TestVhostViaPath(c *check.C) {
 	s.doVhostRequests(c, authzViaPath)
 }

commit 39bed89acebb3cff63831f36b0915378480f4ae5
Author: Peter Amstutz <peter.amstutz at curii.com>
Date:   Wed Nov 29 15:49:27 2023 -0500

    Merge branch '21205-ensure-unique' refs #21205
    
    Arvados-DCO-1.1-Signed-off-by: Peter Amstutz <peter.amstutz at curii.com>

diff --git a/services/api/app/models/arvados_model.rb b/services/api/app/models/arvados_model.rb
index d910320ec0..79ab666ee1 100644
--- a/services/api/app/models/arvados_model.rb
+++ b/services/api/app/models/arvados_model.rb
@@ -24,6 +24,7 @@ class ArvadosModel < ApplicationRecord
   before_destroy :ensure_owner_uuid_is_permitted
   before_destroy :ensure_permission_to_destroy
   before_create :update_modified_by_fields
+  before_create :add_uuid_to_name, :if => Proc.new { @_add_uuid_to_name }
   before_update :maybe_update_modified_by_fields
   after_create :log_create
   after_update :log_update
@@ -470,8 +471,6 @@ class ArvadosModel < ApplicationRecord
   end
 
   def save_with_unique_name!
-    uuid_was = uuid
-    name_was = name
     max_retries = 2
     transaction do
       conn = ActiveRecord::Base.connection
@@ -502,24 +501,20 @@ class ArvadosModel < ApplicationRecord
 
         conn.exec_query 'ROLLBACK TO SAVEPOINT save_with_unique_name'
 
-        new_name = "#{name_was} (#{db_current_time.utc.iso8601(3)})"
-        if new_name == name
-          # If the database is fast enough to do two attempts in the
-          # same millisecond, we need to wait to ensure we try a
-          # different timestamp on each attempt.
-          sleep 0.002
-          new_name = "#{name_was} (#{db_current_time.utc.iso8601(3)})"
-        end
-
-        self[:name] = new_name
-        if uuid_was.nil? && !uuid.nil?
+        if uuid_was.nil?
+          # new record, the uuid caused a name collision (very
+          # unlikely but possible), so generate new uuid
           self[:uuid] = nil
           if self.is_a? Collection
-            # Reset so that is assigned to the new UUID
+            # Also needs to be reset
             self[:current_version_uuid] = nil
           end
+          # need to adjust the name after the uuid has been generated
+          add_uuid_to_make_unique_name
+        else
+          # existing record, just update the name directly.
+          add_uuid_to_name
         end
-
         retry
       end
     end
@@ -580,6 +575,26 @@ class ArvadosModel < ApplicationRecord
                           *ft[:param_out])
   end
 
+  @_add_uuid_to_name = false
+  def add_uuid_to_make_unique_name
+    @_add_uuid_to_name = true
+  end
+
+  def add_uuid_to_name
+    # Incorporate the random part of the UUID into the name.  This
+    # lets us prevent name collision but the part we add to the name
+    # is still somewhat meaningful (instead of generating a second
+    # random meaningless string).
+    #
+    # Because ArvadosModel is an abstract class and assign_uuid is
+    # part of HasUuid (which is included by the other concrete
+    # classes) the assign_uuid hook gets added (and run) after this
+    # one.  So we need to call assign_uuid here to make sure we have a
+    # uuid.
+    assign_uuid
+    self.name = "#{self.name[0..236]} (#{self.uuid[-15..-1]})"
+  end
+
   protected
 
   def self.deep_sort_hash(x)
diff --git a/services/api/test/functional/arvados/v1/collections_controller_test.rb b/services/api/test/functional/arvados/v1/collections_controller_test.rb
index 8a1d044d6a..9e90ef6149 100644
--- a/services/api/test/functional/arvados/v1/collections_controller_test.rb
+++ b/services/api/test/functional/arvados/v1/collections_controller_test.rb
@@ -409,7 +409,7 @@ EOS
         ensure_unique_name: true
       }
       assert_response :success
-      assert_match /^owned_by_active \(\d{4}-\d\d-\d\d.*?Z\)$/, json_response['name']
+      assert_match /^owned_by_active \(#{json_response['uuid'][-15..-1]}\)$/, json_response['name']
     end
   end
 
@@ -1271,7 +1271,7 @@ EOS
     assert_equal false, json_response['is_trashed']
     assert_nil json_response['trash_at']
     assert_nil json_response['delete_at']
-    assert_match /^same name for trashed and persisted collections \(\d{4}-\d\d-\d\d.*?Z\)$/, json_response['name']
+    assert_match /^same name for trashed and persisted collections \(#{json_response['uuid'][-15..-1]}\)$/, json_response['name']
   end
 
   test 'cannot show collection in trashed subproject' do
diff --git a/services/api/test/functional/arvados/v1/groups_controller_test.rb b/services/api/test/functional/arvados/v1/groups_controller_test.rb
index a64ea76692..88d96c51a3 100644
--- a/services/api/test/functional/arvados/v1/groups_controller_test.rb
+++ b/services/api/test/functional/arvados/v1/groups_controller_test.rb
@@ -474,7 +474,7 @@ class Arvados::V1::GroupsControllerTest < ActionController::TestCase
     assert_not_equal(new_project['uuid'],
                      groups(:aproject).uuid,
                      "create returned same uuid as existing project")
-    assert_match(/^A Project \(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\)$/,
+    assert_match(/^A Project \(#{new_project['uuid'][-15..-1]}\)$/,
                  new_project['name'])
   end
 
@@ -800,7 +800,7 @@ class Arvados::V1::GroupsControllerTest < ActionController::TestCase
             ensure_unique_name: true
            }
       assert_response :success
-      assert_match /^trashed subproject 3 \(\d{4}-\d\d-\d\d.*?Z\)$/, json_response['name']
+      assert_match /^trashed subproject 3 \(#{json_response['uuid'][-15..-1]}\)$/, json_response['name']
     end
 
     test "move trashed subproject to new owner #{auth}" do
diff --git a/services/api/test/unit/container_request_test.rb b/services/api/test/unit/container_request_test.rb
index a64adba6ff..0dd27d5ff5 100644
--- a/services/api/test/unit/container_request_test.rb
+++ b/services/api/test/unit/container_request_test.rb
@@ -1131,13 +1131,13 @@ class ContainerRequestTest < ActiveSupport::TestCase
     assert_equal ContainerRequest::Final, cr.state
     output_coll = Collection.find_by_uuid(cr.output_uuid)
     # Make sure the resulting output collection name include the original name
-    # plus the date
+    # plus the last 15 characters of uuid
     assert_not_equal output_name, output_coll.name,
                      "more than one collection with the same owner and name"
     assert output_coll.name.include?(output_name),
            "New name should include original name"
-    assert_match /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/, output_coll.name,
-                 "New name should include ISO8601 date"
+    assert_match /#{output_coll.uuid[-15..-1]}/, output_coll.name,
+                 "New name should include last 15 characters of uuid"
   end
 
   [[0, :check_output_ttl_0],

commit 7a8c6b58534518990d05d895cb33d3f74553755d
Author: Peter Amstutz <peter.amstutz at curii.com>
Date:   Wed Oct 11 10:25:41 2023 -0400

    Close span
    
    refs #20937
    
    Arvados-DCO-1.1-Signed-off-by: Peter Amstutz <peter.amstutz at curii.com>

diff --git a/doc/user/topics/arv-copy.html.textile.liquid b/doc/user/topics/arv-copy.html.textile.liquid
index e7c250ac48..a05620d62d 100644
--- a/doc/user/topics/arv-copy.html.textile.liquid
+++ b/doc/user/topics/arv-copy.html.textile.liquid
@@ -98,7 +98,7 @@ We will use the uuid @jutro-j7d0g-xj19djofle3aryq@ as an example project.
 Arv-copy will infer the source cluster is @jutro@ from the source project uuid, and destination cluster is @pirca@ from @--project-uuid at .
 
 <notextile>
-<pre><code>~$ <span class="userinput">arv-copy --project-uuid pirca-j7d0g-lr8sq3tx3ovn68k jutro-j7d0g-xj19djofle3aryq
+<pre><code>~$ <span class="userinput">arv-copy --project-uuid pirca-j7d0g-lr8sq3tx3ovn68k jutro-j7d0g-xj19djofle3aryq</span>
 2021-09-08 21:29:32 arvados.arv-copy[6377] INFO:
 2021-09-08 21:29:32 arvados.arv-copy[6377] INFO: Success: created copy with uuid pirca-j7d0g-ig9gvu5piznducp
 </code></pre>
@@ -113,7 +113,7 @@ h3. Importing HTTP resources to Keep
 You can also use @arv-copy@ to copy the contents of a HTTP URL into Keep.  When you do this, Arvados keeps track of the original URL the resource came from.  This allows you to refer to the resource by its original URL in Workflow inputs, but actually read from the local copy in Keep.
 
 <notextile>
-<pre><code>~$ <span class="userinput">arv-copy --project-uuid tordo-j7d0g-lr8sq3tx3ovn68k https://example.com/index.html
+<pre><code>~$ <span class="userinput">arv-copy --project-uuid tordo-j7d0g-lr8sq3tx3ovn68k https://example.com/index.html</span>
 tordo-4zz18-dhpb6y9km2byb94
 2023-10-06 10:15:36 arvados.arv-copy[374147] INFO: Success: created copy with uuid tordo-4zz18-dhpb6y9km2byb94
 </code></pre>

commit a24ea36c4b5175229179040e842760fd11ba7b7a
Author: Peter Amstutz <peter.amstutz at curii.com>
Date:   Wed Oct 11 10:16:44 2023 -0400

    Remove duplicated ~$
    
    refs #20937
    
    Arvados-DCO-1.1-Signed-off-by: Peter Amstutz <peter.amstutz at curii.com>

diff --git a/doc/user/topics/arv-copy.html.textile.liquid b/doc/user/topics/arv-copy.html.textile.liquid
index 46ecfb2394..e7c250ac48 100644
--- a/doc/user/topics/arv-copy.html.textile.liquid
+++ b/doc/user/topics/arv-copy.html.textile.liquid
@@ -98,7 +98,7 @@ We will use the uuid @jutro-j7d0g-xj19djofle3aryq@ as an example project.
 Arv-copy will infer the source cluster is @jutro@ from the source project uuid, and destination cluster is @pirca@ from @--project-uuid at .
 
 <notextile>
-<pre><code>~$ <span class="userinput">~$ arv-copy --project-uuid pirca-j7d0g-lr8sq3tx3ovn68k jutro-j7d0g-xj19djofle3aryq
+<pre><code>~$ <span class="userinput">arv-copy --project-uuid pirca-j7d0g-lr8sq3tx3ovn68k jutro-j7d0g-xj19djofle3aryq
 2021-09-08 21:29:32 arvados.arv-copy[6377] INFO:
 2021-09-08 21:29:32 arvados.arv-copy[6377] INFO: Success: created copy with uuid pirca-j7d0g-ig9gvu5piznducp
 </code></pre>
@@ -113,7 +113,7 @@ h3. Importing HTTP resources to Keep
 You can also use @arv-copy@ to copy the contents of a HTTP URL into Keep.  When you do this, Arvados keeps track of the original URL the resource came from.  This allows you to refer to the resource by its original URL in Workflow inputs, but actually read from the local copy in Keep.
 
 <notextile>
-<pre><code>~$ <span class="userinput">~$ arv-copy --project-uuid tordo-j7d0g-lr8sq3tx3ovn68k https://example.com/index.html
+<pre><code>~$ <span class="userinput">arv-copy --project-uuid tordo-j7d0g-lr8sq3tx3ovn68k https://example.com/index.html
 tordo-4zz18-dhpb6y9km2byb94
 2023-10-06 10:15:36 arvados.arv-copy[374147] INFO: Success: created copy with uuid tordo-4zz18-dhpb6y9km2byb94
 </code></pre>

commit b9fbbeca2c603820815cc82293edb795f1e95127
Author: Brett Smith <brett.smith at curii.com>
Date:   Thu Oct 12 14:52:55 2023 -0400

    Merge branch '19821-collection-docstrings'
    
    Closes #19821.
    
    Arvados-DCO-1.1-Signed-off-by: Brett Smith <brett.smith at curii.com>

diff --git a/sdk/python/arvados/collection.py b/sdk/python/arvados/collection.py
index bfb43be5eb..9e6bd06071 100644
--- a/sdk/python/arvados/collection.py
+++ b/sdk/python/arvados/collection.py
@@ -1,6 +1,16 @@
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: Apache-2.0
+"""Tools to work with Arvados collections
+
+This module provides high-level interfaces to create, read, and update
+Arvados collections. Most users will want to instantiate `Collection`
+objects, and use methods like `Collection.open` and `Collection.mkdirs` to
+read and write data in the collection. Refer to the Arvados Python SDK
+cookbook for [an introduction to using the Collection class][cookbook].
+
+[cookbook]: https://doc.arvados.org/sdk/python/cookbook.html#working-with-collections
+"""
 
 from __future__ import absolute_import
 from future.utils import listitems, listvalues, viewkeys
@@ -35,15 +45,65 @@ import arvados.util
 import arvados.events as events
 from arvados.retry import retry_method
 
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    IO,
+    Iterator,
+    List,
+    Mapping,
+    Optional,
+    Tuple,
+    Union,
+)
+
+if sys.version_info < (3, 8):
+    from typing_extensions import Literal
+else:
+    from typing import Literal
+
 _logger = logging.getLogger('arvados.collection')
 
+ADD = "add"
+"""Argument value for `Collection` methods to represent an added item"""
+DEL = "del"
+"""Argument value for `Collection` methods to represent a removed item"""
+MOD = "mod"
+"""Argument value for `Collection` methods to represent a modified item"""
+TOK = "tok"
+"""Argument value for `Collection` methods to represent an item with token differences"""
+FILE = "file"
+"""`create_type` value for `Collection.find_or_create`"""
+COLLECTION = "collection"
+"""`create_type` value for `Collection.find_or_create`"""
+
+ChangeList = List[Union[
+    Tuple[Literal[ADD, DEL], str, 'Collection'],
+    Tuple[Literal[MOD, TOK], str, 'Collection', 'Collection'],
+]]
+ChangeType = Literal[ADD, DEL, MOD, TOK]
+CollectionItem = Union[ArvadosFile, 'Collection']
+ChangeCallback = Callable[[ChangeType, 'Collection', str, CollectionItem], object]
+CreateType = Literal[COLLECTION, FILE]
+Properties = Dict[str, Any]
+StorageClasses = List[str]
+
 class CollectionBase(object):
-    """Abstract base class for Collection classes."""
+    """Abstract base class for Collection classes
+
+    .. ATTENTION:: Internal
+       This class is meant to be used by other parts of the SDK. User code
+       should instantiate or subclass `Collection` or one of its subclasses
+       directly.
+    """
 
     def __enter__(self):
+        """Enter a context block with this collection instance"""
         return self
 
     def __exit__(self, exc_type, exc_value, traceback):
+        """Exit a context block with this collection instance"""
         pass
 
     def _my_keep(self):
@@ -52,12 +112,13 @@ class CollectionBase(object):
                                            num_retries=self.num_retries)
         return self._keep_client
 
-    def stripped_manifest(self):
-        """Get the manifest with locator hints stripped.
+    def stripped_manifest(self) -> str:
+        """Create a copy of the collection manifest with only size hints
 
-        Return the manifest for the current collection with all
-        non-portable hints (i.e., permission signatures and other
-        hints other than size hints) removed from the locators.
+        This method returns a string with the current collection's manifest
+        text with all non-portable locator hints like permission hints and
+        remote cluster hints removed. The only hints in the returned manifest
+        will be size hints.
         """
         raw = self.manifest_text()
         clean = []
@@ -96,709 +157,379 @@ class _WriterFile(_FileLikeObjectBase):
         self.dest.flush_data()
 
 
-class CollectionWriter(CollectionBase):
-    """Deprecated, use Collection instead."""
+class RichCollectionBase(CollectionBase):
+    """Base class for Collection classes
 
-    @arvados.util._deprecated('3.0', 'arvados.collection.Collection')
-    def __init__(self, api_client=None, num_retries=0, replication=None):
-        """Instantiate a CollectionWriter.
+    .. ATTENTION:: Internal
+       This class is meant to be used by other parts of the SDK. User code
+       should instantiate or subclass `Collection` or one of its subclasses
+       directly.
+    """
 
-        CollectionWriter lets you build a new Arvados Collection from scratch.
-        Write files to it.  The CollectionWriter will upload data to Keep as
-        appropriate, and provide you with the Collection manifest text when
-        you're finished.
+    def __init__(self, parent=None):
+        self.parent = parent
+        self._committed = False
+        self._has_remote_blocks = False
+        self._callback = None
+        self._items = {}
 
-        Arguments:
-        * api_client: The API client to use to look up Collections.  If not
-          provided, CollectionReader will build one from available Arvados
-          configuration.
-        * num_retries: The default number of times to retry failed
-          service requests.  Default 0.  You may change this value
-          after instantiation, but note those changes may not
-          propagate to related objects like the Keep client.
-        * replication: The number of copies of each block to store.
-          If this argument is None or not supplied, replication is
-          the server-provided default if available, otherwise 2.
-        """
-        self._api_client = api_client
-        self.num_retries = num_retries
-        self.replication = (2 if replication is None else replication)
-        self._keep_client = None
-        self._data_buffer = []
-        self._data_buffer_len = 0
-        self._current_stream_files = []
-        self._current_stream_length = 0
-        self._current_stream_locators = []
-        self._current_stream_name = '.'
-        self._current_file_name = None
-        self._current_file_pos = 0
-        self._finished_streams = []
-        self._close_file = None
-        self._queued_file = None
-        self._queued_dirents = deque()
-        self._queued_trees = deque()
-        self._last_open = None
+    def _my_api(self):
+        raise NotImplementedError()
 
-    def __exit__(self, exc_type, exc_value, traceback):
-        if exc_type is None:
-            self.finish()
+    def _my_keep(self):
+        raise NotImplementedError()
 
-    def do_queued_work(self):
-        # The work queue consists of three pieces:
-        # * _queued_file: The file object we're currently writing to the
-        #   Collection.
-        # * _queued_dirents: Entries under the current directory
-        #   (_queued_trees[0]) that we want to write or recurse through.
-        #   This may contain files from subdirectories if
-        #   max_manifest_depth == 0 for this directory.
-        # * _queued_trees: Directories that should be written as separate
-        #   streams to the Collection.
-        # This function handles the smallest piece of work currently queued
-        # (current file, then current directory, then next directory) until
-        # no work remains.  The _work_THING methods each do a unit of work on
-        # THING.  _queue_THING methods add a THING to the work queue.
-        while True:
-            if self._queued_file:
-                self._work_file()
-            elif self._queued_dirents:
-                self._work_dirents()
-            elif self._queued_trees:
-                self._work_trees()
-            else:
-                break
+    def _my_block_manager(self):
+        raise NotImplementedError()
 
-    def _work_file(self):
-        while True:
-            buf = self._queued_file.read(config.KEEP_BLOCK_SIZE)
-            if not buf:
-                break
-            self.write(buf)
-        self.finish_current_file()
-        if self._close_file:
-            self._queued_file.close()
-        self._close_file = None
-        self._queued_file = None
+    def writable(self) -> bool:
+        """Indicate whether this collection object can be modified
 
-    def _work_dirents(self):
-        path, stream_name, max_manifest_depth = self._queued_trees[0]
-        if stream_name != self.current_stream_name():
-            self.start_new_stream(stream_name)
-        while self._queued_dirents:
-            dirent = self._queued_dirents.popleft()
-            target = os.path.join(path, dirent)
-            if os.path.isdir(target):
-                self._queue_tree(target,
-                                 os.path.join(stream_name, dirent),
-                                 max_manifest_depth - 1)
-            else:
-                self._queue_file(target, dirent)
-                break
-        if not self._queued_dirents:
-            self._queued_trees.popleft()
+        This method returns `False` if this object is a `CollectionReader`,
+        else `True`.
+        """
+        raise NotImplementedError()
 
-    def _work_trees(self):
-        path, stream_name, max_manifest_depth = self._queued_trees[0]
-        d = arvados.util.listdir_recursive(
-            path, max_depth = (None if max_manifest_depth == 0 else 0))
-        if d:
-            self._queue_dirents(stream_name, d)
-        else:
-            self._queued_trees.popleft()
+    def root_collection(self) -> 'Collection':
+        """Get this collection's root collection object
 
-    def _queue_file(self, source, filename=None):
-        assert (self._queued_file is None), "tried to queue more than one file"
-        if not hasattr(source, 'read'):
-            source = open(source, 'rb')
-            self._close_file = True
-        else:
-            self._close_file = False
-        if filename is None:
-            filename = os.path.basename(source.name)
-        self.start_new_file(filename)
-        self._queued_file = source
+        If you open a subcollection with `Collection.find`, calling this method
+        on that subcollection returns the source Collection object.
+        """
+        raise NotImplementedError()
 
-    def _queue_dirents(self, stream_name, dirents):
-        assert (not self._queued_dirents), "tried to queue more than one tree"
-        self._queued_dirents = deque(sorted(dirents))
+    def stream_name(self) -> str:
+        """Get the name of the manifest stream represented by this collection
 
-    def _queue_tree(self, path, stream_name, max_manifest_depth):
-        self._queued_trees.append((path, stream_name, max_manifest_depth))
+        If you open a subcollection with `Collection.find`, calling this method
+        on that subcollection returns the name of the stream you opened.
+        """
+        raise NotImplementedError()
 
-    def write_file(self, source, filename=None):
-        self._queue_file(source, filename)
-        self.do_queued_work()
+    @synchronized
+    def has_remote_blocks(self) -> bool:
+        """Indiciate whether the collection refers to remote data
 
-    def write_directory_tree(self,
-                             path, stream_name='.', max_manifest_depth=-1):
-        self._queue_tree(path, stream_name, max_manifest_depth)
-        self.do_queued_work()
+        Returns `True` if the collection manifest includes any Keep locators
+        with a remote hint (`+R`), else `False`.
+        """
+        if self._has_remote_blocks:
+            return True
+        for item in self:
+            if self[item].has_remote_blocks():
+                return True
+        return False
 
-    def write(self, newdata):
-        if isinstance(newdata, bytes):
-            pass
-        elif isinstance(newdata, str):
-            newdata = newdata.encode()
-        elif hasattr(newdata, '__iter__'):
-            for s in newdata:
-                self.write(s)
-            return
-        self._data_buffer.append(newdata)
-        self._data_buffer_len += len(newdata)
-        self._current_stream_length += len(newdata)
-        while self._data_buffer_len >= config.KEEP_BLOCK_SIZE:
-            self.flush_data()
+    @synchronized
+    def set_has_remote_blocks(self, val: bool) -> None:
+        """Cache whether this collection refers to remote blocks
 
-    def open(self, streampath, filename=None):
-        """open(streampath[, filename]) -> file-like object
+        .. ATTENTION:: Internal
+           This method is only meant to be used by other Collection methods.
 
-        Pass in the path of a file to write to the Collection, either as a
-        single string or as two separate stream name and file name arguments.
-        This method returns a file-like object you can write to add it to the
-        Collection.
+        Set this collection's cached "has remote blocks" flag to the given
+        value.
+        """
+        self._has_remote_blocks = val
+        if self.parent:
+            self.parent.set_has_remote_blocks(val)
 
-        You may only have one file object from the Collection open at a time,
-        so be sure to close the object when you're done.  Using the object in
-        a with statement makes that easy::
+    @must_be_writable
+    @synchronized
+    def find_or_create(
+            self,
+            path: str,
+            create_type: CreateType,
+    ) -> CollectionItem:
+        """Get the item at the given path, creating it if necessary
+
+        If `path` refers to a stream in this collection, returns a
+        corresponding `Subcollection` object. If `path` refers to a file in
+        this collection, returns a corresponding
+        `arvados.arvfile.ArvadosFile` object. If `path` does not exist in
+        this collection, then this method creates a new object and returns
+        it, creating parent streams as needed. The type of object created is
+        determined by the value of `create_type`.
+
+        Arguments:
+
+        * path: str --- The path to find or create within this collection.
 
-          with cwriter.open('./doc/page1.txt') as outfile:
-              outfile.write(page1_data)
-          with cwriter.open('./doc/page2.txt') as outfile:
-              outfile.write(page2_data)
+        * create_type: Literal[COLLECTION, FILE] --- The type of object to
+          create at `path` if one does not exist. Passing `COLLECTION`
+          creates a stream and returns the corresponding
+          `Subcollection`. Passing `FILE` creates a new file and returns the
+          corresponding `arvados.arvfile.ArvadosFile`.
         """
-        if filename is None:
-            streampath, filename = split(streampath)
-        if self._last_open and not self._last_open.closed:
-            raise errors.AssertionError(
-                u"can't open '{}' when '{}' is still open".format(
-                    filename, self._last_open.name))
-        if streampath != self.current_stream_name():
-            self.start_new_stream(streampath)
-        self.set_current_file_name(filename)
-        self._last_open = _WriterFile(self, filename)
-        return self._last_open
+        pathcomponents = path.split("/", 1)
+        if pathcomponents[0]:
+            item = self._items.get(pathcomponents[0])
+            if len(pathcomponents) == 1:
+                if item is None:
+                    # create new file
+                    if create_type == COLLECTION:
+                        item = Subcollection(self, pathcomponents[0])
+                    else:
+                        item = ArvadosFile(self, pathcomponents[0])
+                    self._items[pathcomponents[0]] = item
+                    self.set_committed(False)
+                    self.notify(ADD, self, pathcomponents[0], item)
+                return item
+            else:
+                if item is None:
+                    # create new collection
+                    item = Subcollection(self, pathcomponents[0])
+                    self._items[pathcomponents[0]] = item
+                    self.set_committed(False)
+                    self.notify(ADD, self, pathcomponents[0], item)
+                if isinstance(item, RichCollectionBase):
+                    return item.find_or_create(pathcomponents[1], create_type)
+                else:
+                    raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0])
+        else:
+            return self
 
-    def flush_data(self):
-        data_buffer = b''.join(self._data_buffer)
-        if data_buffer:
-            self._current_stream_locators.append(
-                self._my_keep().put(
-                    data_buffer[0:config.KEEP_BLOCK_SIZE],
-                    copies=self.replication))
-            self._data_buffer = [data_buffer[config.KEEP_BLOCK_SIZE:]]
-            self._data_buffer_len = len(self._data_buffer[0])
+    @synchronized
+    def find(self, path: str) -> CollectionItem:
+        """Get the item at the given path
 
-    def start_new_file(self, newfilename=None):
-        self.finish_current_file()
-        self.set_current_file_name(newfilename)
+        If `path` refers to a stream in this collection, returns a
+        corresponding `Subcollection` object. If `path` refers to a file in
+        this collection, returns a corresponding
+        `arvados.arvfile.ArvadosFile` object. If `path` does not exist in
+        this collection, then this method raises `NotADirectoryError`.
 
-    def set_current_file_name(self, newfilename):
-        if re.search(r'[\t\n]', newfilename):
-            raise errors.AssertionError(
-                "Manifest filenames cannot contain whitespace: %s" %
-                newfilename)
-        elif re.search(r'\x00', newfilename):
-            raise errors.AssertionError(
-                "Manifest filenames cannot contain NUL characters: %s" %
-                newfilename)
-        self._current_file_name = newfilename
+        Arguments:
 
-    def current_file_name(self):
-        return self._current_file_name
+        * path: str --- The path to find or create within this collection.
+        """
+        if not path:
+            raise errors.ArgumentError("Parameter 'path' is empty.")
 
-    def finish_current_file(self):
-        if self._current_file_name is None:
-            if self._current_file_pos == self._current_stream_length:
-                return
-            raise errors.AssertionError(
-                "Cannot finish an unnamed file " +
-                "(%d bytes at offset %d in '%s' stream)" %
-                (self._current_stream_length - self._current_file_pos,
-                 self._current_file_pos,
-                 self._current_stream_name))
-        self._current_stream_files.append([
-                self._current_file_pos,
-                self._current_stream_length - self._current_file_pos,
-                self._current_file_name])
-        self._current_file_pos = self._current_stream_length
-        self._current_file_name = None
+        pathcomponents = path.split("/", 1)
+        if pathcomponents[0] == '':
+            raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0])
 
-    def start_new_stream(self, newstreamname='.'):
-        self.finish_current_stream()
-        self.set_current_stream_name(newstreamname)
+        item = self._items.get(pathcomponents[0])
+        if item is None:
+            return None
+        elif len(pathcomponents) == 1:
+            return item
+        else:
+            if isinstance(item, RichCollectionBase):
+                if pathcomponents[1]:
+                    return item.find(pathcomponents[1])
+                else:
+                    return item
+            else:
+                raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0])
 
-    def set_current_stream_name(self, newstreamname):
-        if re.search(r'[\t\n]', newstreamname):
-            raise errors.AssertionError(
-                "Manifest stream names cannot contain whitespace: '%s'" %
-                (newstreamname))
-        self._current_stream_name = '.' if newstreamname=='' else newstreamname
+    @synchronized
+    def mkdirs(self, path: str) -> 'Subcollection':
+        """Create and return a subcollection at `path`
 
-    def current_stream_name(self):
-        return self._current_stream_name
+        If `path` exists within this collection, raises `FileExistsError`.
+        Otherwise, creates a stream at that path and returns the
+        corresponding `Subcollection`.
+        """
+        if self.find(path) != None:
+            raise IOError(errno.EEXIST, "Directory or file exists", path)
 
-    def finish_current_stream(self):
-        self.finish_current_file()
-        self.flush_data()
-        if not self._current_stream_files:
-            pass
-        elif self._current_stream_name is None:
-            raise errors.AssertionError(
-                "Cannot finish an unnamed stream (%d bytes in %d files)" %
-                (self._current_stream_length, len(self._current_stream_files)))
-        else:
-            if not self._current_stream_locators:
-                self._current_stream_locators.append(config.EMPTY_BLOCK_LOCATOR)
-            self._finished_streams.append([self._current_stream_name,
-                                           self._current_stream_locators,
-                                           self._current_stream_files])
-        self._current_stream_files = []
-        self._current_stream_length = 0
-        self._current_stream_locators = []
-        self._current_stream_name = None
-        self._current_file_pos = 0
-        self._current_file_name = None
+        return self.find_or_create(path, COLLECTION)
 
-    def finish(self):
-        """Store the manifest in Keep and return its locator.
+    def open(
+            self,
+            path: str,
+            mode: str="r",
+            encoding: Optional[str]=None,
+    ) -> IO:
+        """Open a file-like object within the collection
 
-        This is useful for storing manifest fragments (task outputs)
-        temporarily in Keep during a Crunch job.
+        This method returns a file-like object that can read and/or write the
+        file located at `path` within the collection. If you attempt to write
+        a `path` that does not exist, the file is created with `find_or_create`.
+        If the file cannot be opened for any other reason, this method raises
+        `OSError` with an appropriate errno.
 
-        In other cases you should make a collection instead, by
-        sending manifest_text() to the API server's "create
-        collection" endpoint.
-        """
-        return self._my_keep().put(self.manifest_text().encode(),
-                                   copies=self.replication)
+        Arguments:
 
-    def portable_data_hash(self):
-        stripped = self.stripped_manifest().encode()
-        return '{}+{}'.format(hashlib.md5(stripped).hexdigest(), len(stripped))
+        * path: str --- The path of the file to open within this collection
 
-    def manifest_text(self):
-        self.finish_current_stream()
-        manifest = ''
+        * mode: str --- The mode to open this file. Supports all the same
+          values as `builtins.open`.
 
-        for stream in self._finished_streams:
-            if not re.search(r'^\.(/.*)?$', stream[0]):
-                manifest += './'
-            manifest += stream[0].replace(' ', '\\040')
-            manifest += ' ' + ' '.join(stream[1])
-            manifest += ' ' + ' '.join("%d:%d:%s" % (sfile[0], sfile[1], sfile[2].replace(' ', '\\040')) for sfile in stream[2])
-            manifest += "\n"
+        * encoding: str | None --- The text encoding of the file. Only used
+          when the file is opened in text mode. The default is
+          platform-dependent.
+        """
+        if not re.search(r'^[rwa][bt]?\+?$', mode):
+            raise errors.ArgumentError("Invalid mode {!r}".format(mode))
 
-        return manifest
+        if mode[0] == 'r' and '+' not in mode:
+            fclass = ArvadosFileReader
+            arvfile = self.find(path)
+        elif not self.writable():
+            raise IOError(errno.EROFS, "Collection is read only")
+        else:
+            fclass = ArvadosFileWriter
+            arvfile = self.find_or_create(path, FILE)
 
-    def data_locators(self):
-        ret = []
-        for name, locators, files in self._finished_streams:
-            ret += locators
-        return ret
+        if arvfile is None:
+            raise IOError(errno.ENOENT, "File not found", path)
+        if not isinstance(arvfile, ArvadosFile):
+            raise IOError(errno.EISDIR, "Is a directory", path)
 
-    def save_new(self, name=None):
-        return self._api_client.collections().create(
-            ensure_unique_name=True,
-            body={
-                'name': name,
-                'manifest_text': self.manifest_text(),
-            }).execute(num_retries=self.num_retries)
+        if mode[0] == 'w':
+            arvfile.truncate(0)
 
+        binmode = mode[0] + 'b' + re.sub('[bt]', '', mode[1:])
+        f = fclass(arvfile, mode=binmode, num_retries=self.num_retries)
+        if 'b' not in mode:
+            bufferclass = io.BufferedRandom if f.writable() else io.BufferedReader
+            f = io.TextIOWrapper(bufferclass(WrappableFile(f)), encoding=encoding)
+        return f
 
-class ResumableCollectionWriter(CollectionWriter):
-    """Deprecated, use Collection instead."""
+    def modified(self) -> bool:
+        """Indicate whether this collection has an API server record
 
-    STATE_PROPS = ['_current_stream_files', '_current_stream_length',
-                   '_current_stream_locators', '_current_stream_name',
-                   '_current_file_name', '_current_file_pos', '_close_file',
-                   '_data_buffer', '_dependencies', '_finished_streams',
-                   '_queued_dirents', '_queued_trees']
+        Returns `False` if this collection corresponds to a record loaded from
+        the API server, `True` otherwise.
+        """
+        return not self.committed()
 
-    @arvados.util._deprecated('3.0', 'arvados.collection.Collection')
-    def __init__(self, api_client=None, **kwargs):
-        self._dependencies = {}
-        super(ResumableCollectionWriter, self).__init__(api_client, **kwargs)
+    @synchronized
+    def committed(self):
+        """Indicate whether this collection has an API server record
 
-    @classmethod
-    def from_state(cls, state, *init_args, **init_kwargs):
-        # Try to build a new writer from scratch with the given state.
-        # If the state is not suitable to resume (because files have changed,
-        # been deleted, aren't predictable, etc.), raise a
-        # StaleWriterStateError.  Otherwise, return the initialized writer.
-        # The caller is responsible for calling writer.do_queued_work()
-        # appropriately after it's returned.
-        writer = cls(*init_args, **init_kwargs)
-        for attr_name in cls.STATE_PROPS:
-            attr_value = state[attr_name]
-            attr_class = getattr(writer, attr_name).__class__
-            # Coerce the value into the same type as the initial value, if
-            # needed.
-            if attr_class not in (type(None), attr_value.__class__):
-                attr_value = attr_class(attr_value)
-            setattr(writer, attr_name, attr_value)
-        # Check dependencies before we try to resume anything.
-        if any(KeepLocator(ls).permission_expired()
-               for ls in writer._current_stream_locators):
-            raise errors.StaleWriterStateError(
-                "locators include expired permission hint")
-        writer.check_dependencies()
-        if state['_current_file'] is not None:
-            path, pos = state['_current_file']
-            try:
-                writer._queued_file = open(path, 'rb')
-                writer._queued_file.seek(pos)
-            except IOError as error:
-                raise errors.StaleWriterStateError(
-                    u"failed to reopen active file {}: {}".format(path, error))
-        return writer
+        Returns `True` if this collection corresponds to a record loaded from
+        the API server, `False` otherwise.
+        """
+        return self._committed
 
-    def check_dependencies(self):
-        for path, orig_stat in listitems(self._dependencies):
-            if not S_ISREG(orig_stat[ST_MODE]):
-                raise errors.StaleWriterStateError(u"{} not file".format(path))
-            try:
-                now_stat = tuple(os.stat(path))
-            except OSError as error:
-                raise errors.StaleWriterStateError(
-                    u"failed to stat {}: {}".format(path, error))
-            if ((not S_ISREG(now_stat[ST_MODE])) or
-                (orig_stat[ST_MTIME] != now_stat[ST_MTIME]) or
-                (orig_stat[ST_SIZE] != now_stat[ST_SIZE])):
-                raise errors.StaleWriterStateError(u"{} changed".format(path))
+    @synchronized
+    def set_committed(self, value: bool=True):
+        """Cache whether this collection has an API server record
 
-    def dump_state(self, copy_func=lambda x: x):
-        state = {attr: copy_func(getattr(self, attr))
-                 for attr in self.STATE_PROPS}
-        if self._queued_file is None:
-            state['_current_file'] = None
-        else:
-            state['_current_file'] = (os.path.realpath(self._queued_file.name),
-                                      self._queued_file.tell())
-        return state
+        .. ATTENTION:: Internal
+           This method is only meant to be used by other Collection methods.
 
-    def _queue_file(self, source, filename=None):
-        try:
-            src_path = os.path.realpath(source)
-        except Exception:
-            raise errors.AssertionError(u"{} not a file path".format(source))
-        try:
-            path_stat = os.stat(src_path)
-        except OSError as stat_error:
-            path_stat = None
-        super(ResumableCollectionWriter, self)._queue_file(source, filename)
-        fd_stat = os.fstat(self._queued_file.fileno())
-        if not S_ISREG(fd_stat.st_mode):
-            # We won't be able to resume from this cache anyway, so don't
-            # worry about further checks.
-            self._dependencies[source] = tuple(fd_stat)
-        elif path_stat is None:
-            raise errors.AssertionError(
-                u"could not stat {}: {}".format(source, stat_error))
-        elif path_stat.st_ino != fd_stat.st_ino:
-            raise errors.AssertionError(
-                u"{} changed between open and stat calls".format(source))
+        Set this collection's cached "committed" flag to the given
+        value and propagates it as needed.
+        """
+        if value == self._committed:
+            return
+        if value:
+            for k,v in listitems(self._items):
+                v.set_committed(True)
+            self._committed = True
         else:
-            self._dependencies[src_path] = tuple(fd_stat)
+            self._committed = False
+            if self.parent is not None:
+                self.parent.set_committed(False)
 
-    def write(self, data):
-        if self._queued_file is None:
-            raise errors.AssertionError(
-                "resumable writer can't accept unsourced data")
-        return super(ResumableCollectionWriter, self).write(data)
+    @synchronized
+    def __iter__(self) -> Iterator[str]:
+        """Iterate names of streams and files in this collection
 
+        This method does not recurse. It only iterates the contents of this
+        collection's corresponding stream.
+        """
+        return iter(viewkeys(self._items))
 
-ADD = "add"
-DEL = "del"
-MOD = "mod"
-TOK = "tok"
-FILE = "file"
-COLLECTION = "collection"
+    @synchronized
+    def __getitem__(self, k: str) -> CollectionItem:
+        """Get a `arvados.arvfile.ArvadosFile` or `Subcollection` in this collection
 
-class RichCollectionBase(CollectionBase):
-    """Base class for Collections and Subcollections.
+        This method does not recurse. If you want to search a path, use
+        `RichCollectionBase.find` instead.
+        """
+        return self._items[k]
 
-    Implements the majority of functionality relating to accessing items in the
-    Collection.
-
-    """
-
-    def __init__(self, parent=None):
-        self.parent = parent
-        self._committed = False
-        self._has_remote_blocks = False
-        self._callback = None
-        self._items = {}
-
-    def _my_api(self):
-        raise NotImplementedError()
-
-    def _my_keep(self):
-        raise NotImplementedError()
-
-    def _my_block_manager(self):
-        raise NotImplementedError()
-
-    def writable(self):
-        raise NotImplementedError()
-
-    def root_collection(self):
-        raise NotImplementedError()
-
-    def notify(self, event, collection, name, item):
-        raise NotImplementedError()
-
-    def stream_name(self):
-        raise NotImplementedError()
+    @synchronized
+    def __contains__(self, k: str) -> bool:
+        """Indicate whether this collection has an item with this name
 
+        This method does not recurse. It you want to check a path, use
+        `RichCollectionBase.exists` instead.
+        """
+        return k in self._items
 
     @synchronized
-    def has_remote_blocks(self):
-        """Recursively check for a +R segment locator signature."""
-
-        if self._has_remote_blocks:
-            return True
-        for item in self:
-            if self[item].has_remote_blocks():
-                return True
-        return False
+    def __len__(self):
+        """Get the number of items directly contained in this collection
 
-    @synchronized
-    def set_has_remote_blocks(self, val):
-        self._has_remote_blocks = val
-        if self.parent:
-            self.parent.set_has_remote_blocks(val)
+        This method does not recurse. It only counts the streams and files
+        in this collection's corresponding stream.
+        """
+        return len(self._items)
 
     @must_be_writable
     @synchronized
-    def find_or_create(self, path, create_type):
-        """Recursively search the specified file path.
-
-        May return either a `Collection` or `ArvadosFile`.  If not found, will
-        create a new item at the specified path based on `create_type`.  Will
-        create intermediate subcollections needed to contain the final item in
-        the path.
-
-        :create_type:
-          One of `arvados.collection.FILE` or
-          `arvados.collection.COLLECTION`.  If the path is not found, and value
-          of create_type is FILE then create and return a new ArvadosFile for
-          the last path component.  If COLLECTION, then create and return a new
-          Collection for the last path component.
+    def __delitem__(self, p: str) -> None:
+        """Delete an item from this collection's stream
 
+        This method does not recurse. If you want to remove an item by a
+        path, use `RichCollectionBase.remove` instead.
         """
-
-        pathcomponents = path.split("/", 1)
-        if pathcomponents[0]:
-            item = self._items.get(pathcomponents[0])
-            if len(pathcomponents) == 1:
-                if item is None:
-                    # create new file
-                    if create_type == COLLECTION:
-                        item = Subcollection(self, pathcomponents[0])
-                    else:
-                        item = ArvadosFile(self, pathcomponents[0])
-                    self._items[pathcomponents[0]] = item
-                    self.set_committed(False)
-                    self.notify(ADD, self, pathcomponents[0], item)
-                return item
-            else:
-                if item is None:
-                    # create new collection
-                    item = Subcollection(self, pathcomponents[0])
-                    self._items[pathcomponents[0]] = item
-                    self.set_committed(False)
-                    self.notify(ADD, self, pathcomponents[0], item)
-                if isinstance(item, RichCollectionBase):
-                    return item.find_or_create(pathcomponents[1], create_type)
-                else:
-                    raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0])
-        else:
-            return self
+        del self._items[p]
+        self.set_committed(False)
+        self.notify(DEL, self, p, None)
 
     @synchronized
-    def find(self, path):
-        """Recursively search the specified file path.
-
-        May return either a Collection or ArvadosFile. Return None if not
-        found.
-        If path is invalid (ex: starts with '/'), an IOError exception will be
-        raised.
+    def keys(self) -> Iterator[str]:
+        """Iterate names of streams and files in this collection
 
+        This method does not recurse. It only iterates the contents of this
+        collection's corresponding stream.
         """
-        if not path:
-            raise errors.ArgumentError("Parameter 'path' is empty.")
-
-        pathcomponents = path.split("/", 1)
-        if pathcomponents[0] == '':
-            raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0])
-
-        item = self._items.get(pathcomponents[0])
-        if item is None:
-            return None
-        elif len(pathcomponents) == 1:
-            return item
-        else:
-            if isinstance(item, RichCollectionBase):
-                if pathcomponents[1]:
-                    return item.find(pathcomponents[1])
-                else:
-                    return item
-            else:
-                raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0])
+        return self._items.keys()
 
     @synchronized
-    def mkdirs(self, path):
-        """Recursive subcollection create.
-
-        Like `os.makedirs()`.  Will create intermediate subcollections needed
-        to contain the leaf subcollection path.
-
-        """
-
-        if self.find(path) != None:
-            raise IOError(errno.EEXIST, "Directory or file exists", path)
-
-        return self.find_or_create(path, COLLECTION)
-
-    def open(self, path, mode="r", encoding=None):
-        """Open a file-like object for access.
-
-        :path:
-          path to a file in the collection
-        :mode:
-          a string consisting of "r", "w", or "a", optionally followed
-          by "b" or "t", optionally followed by "+".
-          :"b":
-            binary mode: write() accepts bytes, read() returns bytes.
-          :"t":
-            text mode (default): write() accepts strings, read() returns strings.
-          :"r":
-            opens for reading
-          :"r+":
-            opens for reading and writing.  Reads/writes share a file pointer.
-          :"w", "w+":
-            truncates to 0 and opens for reading and writing.  Reads/writes share a file pointer.
-          :"a", "a+":
-            opens for reading and writing.  All writes are appended to
-            the end of the file.  Writing does not affect the file pointer for
-            reading.
+    def values(self) -> List[CollectionItem]:
+        """Get a list of objects in this collection's stream
 
+        The return value includes a `Subcollection` for every stream, and an
+        `arvados.arvfile.ArvadosFile` for every file, directly within this
+        collection's stream.  This method does not recurse.
         """
-
-        if not re.search(r'^[rwa][bt]?\+?$', mode):
-            raise errors.ArgumentError("Invalid mode {!r}".format(mode))
-
-        if mode[0] == 'r' and '+' not in mode:
-            fclass = ArvadosFileReader
-            arvfile = self.find(path)
-        elif not self.writable():
-            raise IOError(errno.EROFS, "Collection is read only")
-        else:
-            fclass = ArvadosFileWriter
-            arvfile = self.find_or_create(path, FILE)
-
-        if arvfile is None:
-            raise IOError(errno.ENOENT, "File not found", path)
-        if not isinstance(arvfile, ArvadosFile):
-            raise IOError(errno.EISDIR, "Is a directory", path)
-
-        if mode[0] == 'w':
-            arvfile.truncate(0)
-
-        binmode = mode[0] + 'b' + re.sub('[bt]', '', mode[1:])
-        f = fclass(arvfile, mode=binmode, num_retries=self.num_retries)
-        if 'b' not in mode:
-            bufferclass = io.BufferedRandom if f.writable() else io.BufferedReader
-            f = io.TextIOWrapper(bufferclass(WrappableFile(f)), encoding=encoding)
-        return f
-
-    def modified(self):
-        """Determine if the collection has been modified since last commited."""
-        return not self.committed()
-
-    @synchronized
-    def committed(self):
-        """Determine if the collection has been committed to the API server."""
-        return self._committed
+        return listvalues(self._items)
 
     @synchronized
-    def set_committed(self, value=True):
-        """Recursively set committed flag.
+    def items(self) -> List[Tuple[str, CollectionItem]]:
+        """Get a list of `(name, object)` tuples from this collection's stream
 
-        If value is True, set committed to be True for this and all children.
-
-        If value is False, set committed to be False for this and all parents.
+        The return value includes a `Subcollection` for every stream, and an
+        `arvados.arvfile.ArvadosFile` for every file, directly within this
+        collection's stream.  This method does not recurse.
         """
-        if value == self._committed:
-            return
-        if value:
-            for k,v in listitems(self._items):
-                v.set_committed(True)
-            self._committed = True
-        else:
-            self._committed = False
-            if self.parent is not None:
-                self.parent.set_committed(False)
+        return listitems(self._items)
 
-    @synchronized
-    def __iter__(self):
-        """Iterate over names of files and collections contained in this collection."""
-        return iter(viewkeys(self._items))
+    def exists(self, path: str) -> bool:
+        """Indicate whether this collection includes an item at `path`
 
-    @synchronized
-    def __getitem__(self, k):
-        """Get a file or collection that is directly contained by this collection.
+        This method returns `True` if `path` refers to a stream or file within
+        this collection, else `False`.
 
-        If you want to search a path, use `find()` instead.
+        Arguments:
 
+        * path: str --- The path to check for existence within this collection
         """
-        return self._items[k]
-
-    @synchronized
-    def __contains__(self, k):
-        """Test if there is a file or collection a directly contained by this collection."""
-        return k in self._items
-
-    @synchronized
-    def __len__(self):
-        """Get the number of items directly contained in this collection."""
-        return len(self._items)
+        return self.find(path) is not None
 
     @must_be_writable
     @synchronized
-    def __delitem__(self, p):
-        """Delete an item by name which is directly contained by this collection."""
-        del self._items[p]
-        self.set_committed(False)
-        self.notify(DEL, self, p, None)
-
-    @synchronized
-    def keys(self):
-        """Get a list of names of files and collections directly contained in this collection."""
-        return self._items.keys()
-
-    @synchronized
-    def values(self):
-        """Get a list of files and collection objects directly contained in this collection."""
-        return listvalues(self._items)
-
-    @synchronized
-    def items(self):
-        """Get a list of (name, object) tuples directly contained in this collection."""
-        return listitems(self._items)
+    def remove(self, path: str, recursive: bool=False) -> None:
+        """Remove the file or stream at `path`
 
-    def exists(self, path):
-        """Test if there is a file or collection at `path`."""
-        return self.find(path) is not None
+        Arguments:
 
-    @must_be_writable
-    @synchronized
-    def remove(self, path, recursive=False):
-        """Remove the file or subcollection (directory) at `path`.
+        * path: str --- The path of the item to remove from the collection
 
-        :recursive:
-          Specify whether to remove non-empty subcollections (True), or raise an error (False).
+        * recursive: bool --- Controls the method's behavior if `path` refers
+          to a nonempty stream. If `False` (the default), this method raises
+          `OSError` with errno `ENOTEMPTY`. If `True`, this method removes all
+          items under the stream.
         """
-
         if not path:
             raise errors.ArgumentError("Parameter 'path' is empty.")
 
@@ -825,26 +556,33 @@ class RichCollectionBase(CollectionBase):
 
     @must_be_writable
     @synchronized
-    def add(self, source_obj, target_name, overwrite=False, reparent=False):
-        """Copy or move a file or subcollection to this collection.
+    def add(
+            self,
+            source_obj: CollectionItem,
+            target_name: str,
+            overwrite: bool=False,
+            reparent: bool=False,
+    ) -> None:
+        """Copy or move a file or subcollection object to this collection
 
-        :source_obj:
-          An ArvadosFile, or Subcollection object
+        Arguments:
 
-        :target_name:
-          Destination item name.  If the target name already exists and is a
-          file, this will raise an error unless you specify `overwrite=True`.
+        * source_obj: arvados.arvfile.ArvadosFile | Subcollection --- The file or subcollection
+          to add to this collection
 
-        :overwrite:
-          Whether to overwrite target file if it already exists.
+        * target_name: str --- The path inside this collection where
+          `source_obj` should be added.
 
-        :reparent:
-          If True, source_obj will be moved from its parent collection to this collection.
-          If False, source_obj will be copied and the parent collection will be
-          unmodified.
+        * overwrite: bool --- Controls the behavior of this method when the
+          collection already contains an object at `target_name`. If `False`
+          (the default), this method will raise `FileExistsError`. If `True`,
+          the object at `target_name` will be replaced with `source_obj`.
 
+        * reparent: bool --- Controls whether this method copies or moves
+          `source_obj`. If `False` (the default), `source_obj` is copied into
+          this collection. If `True`, `source_obj` is moved into this
+          collection.
         """
-
         if target_name in self and not overwrite:
             raise IOError(errno.EEXIST, "File already exists", target_name)
 
@@ -911,92 +649,117 @@ class RichCollectionBase(CollectionBase):
 
     @must_be_writable
     @synchronized
-    def copy(self, source, target_path, source_collection=None, overwrite=False):
-        """Copy a file or subcollection to a new path in this collection.
+    def copy(
+            self,
+            source: Union[str, CollectionItem],
+            target_path: str,
+            source_collection: Optional['RichCollectionBase']=None,
+            overwrite: bool=False,
+    ) -> None:
+        """Copy a file or subcollection object to this collection
 
-        :source:
-          A string with a path to source file or subcollection, or an actual ArvadosFile or Subcollection object.
+        Arguments:
 
-        :target_path:
-          Destination file or path.  If the target path already exists and is a
-          subcollection, the item will be placed inside the subcollection.  If
-          the target path already exists and is a file, this will raise an error
-          unless you specify `overwrite=True`.
+        * source: str | arvados.arvfile.ArvadosFile |
+          arvados.collection.Subcollection --- The file or subcollection to
+          add to this collection. If `source` is a str, the object will be
+          found by looking up this path from `source_collection` (see
+          below).
 
-        :source_collection:
-          Collection to copy `source_path` from (default `self`)
+        * target_path: str --- The path inside this collection where the
+          source object should be added.
 
-        :overwrite:
-          Whether to overwrite target file if it already exists.
-        """
+        * source_collection: arvados.collection.Collection | None --- The
+          collection to find the source object from when `source` is a
+          path. Defaults to the current collection (`self`).
 
+        * overwrite: bool --- Controls the behavior of this method when the
+          collection already contains an object at `target_path`. If `False`
+          (the default), this method will raise `FileExistsError`. If `True`,
+          the object at `target_path` will be replaced with `source_obj`.
+        """
         source_obj, target_dir, target_name = self._get_src_target(source, target_path, source_collection, True)
         target_dir.add(source_obj, target_name, overwrite, False)
 
     @must_be_writable
     @synchronized
-    def rename(self, source, target_path, source_collection=None, overwrite=False):
-        """Move a file or subcollection from `source_collection` to a new path in this collection.
+    def rename(
+            self,
+            source: Union[str, CollectionItem],
+            target_path: str,
+            source_collection: Optional['RichCollectionBase']=None,
+            overwrite: bool=False,
+    ) -> None:
+        """Move a file or subcollection object to this collection
+
+        Arguments:
 
-        :source:
-          A string with a path to source file or subcollection.
+        * source: str | arvados.arvfile.ArvadosFile |
+          arvados.collection.Subcollection --- The file or subcollection to
+          add to this collection. If `source` is a str, the object will be
+          found by looking up this path from `source_collection` (see
+          below).
 
-        :target_path:
-          Destination file or path.  If the target path already exists and is a
-          subcollection, the item will be placed inside the subcollection.  If
-          the target path already exists and is a file, this will raise an error
-          unless you specify `overwrite=True`.
+        * target_path: str --- The path inside this collection where the
+          source object should be added.
 
-        :source_collection:
-          Collection to copy `source_path` from (default `self`)
+        * source_collection: arvados.collection.Collection | None --- The
+          collection to find the source object from when `source` is a
+          path. Defaults to the current collection (`self`).
 
-        :overwrite:
-          Whether to overwrite target file if it already exists.
+        * overwrite: bool --- Controls the behavior of this method when the
+          collection already contains an object at `target_path`. If `False`
+          (the default), this method will raise `FileExistsError`. If `True`,
+          the object at `target_path` will be replaced with `source_obj`.
         """
-
         source_obj, target_dir, target_name = self._get_src_target(source, target_path, source_collection, False)
         if not source_obj.writable():
             raise IOError(errno.EROFS, "Source collection is read only", source)
         target_dir.add(source_obj, target_name, overwrite, True)
 
-    def portable_manifest_text(self, stream_name="."):
-        """Get the manifest text for this collection, sub collections and files.
+    def portable_manifest_text(self, stream_name: str=".") -> str:
+        """Get the portable manifest text for this collection
 
-        This method does not flush outstanding blocks to Keep.  It will return
-        a normalized manifest with access tokens stripped.
+        The portable manifest text is normalized, and does not include access
+        tokens. This method does not flush outstanding blocks to Keep.
 
-        :stream_name:
-          Name to use for this stream (directory)
+        Arguments:
 
+        * stream_name: str --- The name to use for this collection's stream in
+          the generated manifest. Default `'.'`.
         """
         return self._get_manifest_text(stream_name, True, True)
 
     @synchronized
-    def manifest_text(self, stream_name=".", strip=False, normalize=False,
-                      only_committed=False):
-        """Get the manifest text for this collection, sub collections and files.
-
-        This method will flush outstanding blocks to Keep.  By default, it will
-        not normalize an unmodified manifest or strip access tokens.
+    def manifest_text(
+            self,
+            stream_name: str=".",
+            strip: bool=False,
+            normalize: bool=False,
+            only_committed: bool=False,
+    ) -> str:
+        """Get the manifest text for this collection
 
-        :stream_name:
-          Name to use for this stream (directory)
+        Arguments:
 
-        :strip:
-          If True, remove signing tokens from block locators if present.
-          If False (default), block locators are left unchanged.
+        * stream_name: str --- The name to use for this collection's stream in
+          the generated manifest. Default `'.'`.
 
-        :normalize:
-          If True, always export the manifest text in normalized form
-          even if the Collection is not modified.  If False (default) and the collection
-          is not modified, return the original manifest text even if it is not
-          in normalized form.
+        * strip: bool --- Controls whether or not the returned manifest text
+          includes access tokens. If `False` (the default), the manifest text
+          will include access tokens. If `True`, the manifest text will not
+          include access tokens.
 
-        :only_committed:
-          If True, don't commit pending blocks.
+        * normalize: bool --- Controls whether or not the returned manifest
+          text is normalized. Default `False`.
 
+        * only_committed: bool --- Controls whether or not this method uploads
+          pending data to Keep before building and returning the manifest text.
+          If `False` (the default), this method will finish uploading all data
+          to Keep, then return the final manifest. If `True`, this method will
+          build and return a manifest that only refers to the data that has
+          finished uploading at the time this method was called.
         """
-
         if not only_committed:
             self._my_block_manager().commit_all()
         return self._get_manifest_text(stream_name, strip, normalize,
@@ -1075,11 +838,27 @@ class RichCollectionBase(CollectionBase):
         return remote_blocks
 
     @synchronized
-    def diff(self, end_collection, prefix=".", holding_collection=None):
-        """Generate list of add/modify/delete actions.
+    def diff(
+            self,
+            end_collection: 'RichCollectionBase',
+            prefix: str=".",
+            holding_collection: Optional['Collection']=None,
+    ) -> ChangeList:
+        """Build a list of differences between this collection and another
+
+        Arguments:
+
+        * end_collection: arvados.collection.RichCollectionBase --- A
+          collection object with the desired end state. The returned diff
+          list will describe how to go from the current collection object
+          `self` to `end_collection`.
 
-        When given to `apply`, will change `self` to match `end_collection`
+        * prefix: str --- The name to use for this collection's stream in
+          the diff list. Default `'.'`.
 
+        * holding_collection: arvados.collection.Collection | None --- A
+          collection object used to hold objects for the returned diff
+          list. By default, a new empty collection is created.
         """
         changes = []
         if holding_collection is None:
@@ -1101,12 +880,20 @@ class RichCollectionBase(CollectionBase):
 
     @must_be_writable
     @synchronized
-    def apply(self, changes):
-        """Apply changes from `diff`.
+    def apply(self, changes: ChangeList) -> None:
+        """Apply a list of changes from to this collection
 
-        If a change conflicts with a local change, it will be saved to an
-        alternate path indicating the conflict.
+        This method takes a list of changes generated by
+        `RichCollectionBase.diff` and applies it to this
+        collection. Afterward, the state of this collection object will
+        match the state of `end_collection` passed to `diff`. If a change
+        conflicts with a local change, it will be saved to an alternate path
+        indicating the conflict.
 
+        Arguments:
+
+        * changes: arvados.collection.ChangeList --- The list of differences
+          generated by `RichCollectionBase.diff`.
         """
         if changes:
             self.set_committed(False)
@@ -1148,8 +935,8 @@ class RichCollectionBase(CollectionBase):
                 # else, the file is modified or already removed, in either
                 # case we don't want to try to remove it.
 
-    def portable_data_hash(self):
-        """Get the portable data hash for this collection's manifest."""
+    def portable_data_hash(self) -> str:
+        """Get the portable data hash for this collection's manifest"""
         if self._manifest_locator and self.committed():
             # If the collection is already saved on the API server, and it's committed
             # then return API server's PDH response.
@@ -1159,25 +946,64 @@ class RichCollectionBase(CollectionBase):
             return '{}+{}'.format(hashlib.md5(stripped).hexdigest(), len(stripped))
 
     @synchronized
-    def subscribe(self, callback):
+    def subscribe(self, callback: ChangeCallback) -> None:
+        """Set a notify callback for changes to this collection
+
+        Arguments:
+
+        * callback: arvados.collection.ChangeCallback --- The callable to
+          call each time the collection is changed.
+        """
         if self._callback is None:
             self._callback = callback
         else:
             raise errors.ArgumentError("A callback is already set on this collection.")
 
     @synchronized
-    def unsubscribe(self):
+    def unsubscribe(self) -> None:
+        """Remove any notify callback set for changes to this collection"""
         if self._callback is not None:
             self._callback = None
 
     @synchronized
-    def notify(self, event, collection, name, item):
+    def notify(
+            self,
+            event: ChangeType,
+            collection: 'RichCollectionBase',
+            name: str,
+            item: CollectionItem,
+    ) -> None:
+        """Notify any subscribed callback about a change to this collection
+
+        .. ATTENTION:: Internal
+           This method is only meant to be used by other Collection methods.
+
+        If a callback has been registered with `RichCollectionBase.subscribe`,
+        it will be called with information about a change to this collection.
+        Then this notification will be propagated to this collection's root.
+
+        Arguments:
+
+        * event: Literal[ADD, DEL, MOD, TOK] --- The type of modification to
+          the collection.
+
+        * collection: arvados.collection.RichCollectionBase --- The
+          collection that was modified.
+
+        * name: str --- The name of the file or stream within `collection` that
+          was modified.
+
+        * item: arvados.arvfile.ArvadosFile |
+          arvados.collection.Subcollection --- The new contents at `name`
+          within `collection`.
+        """
         if self._callback:
             self._callback(event, collection, name, item)
         self.root_collection().notify(event, collection, name, item)
 
     @synchronized
-    def __eq__(self, other):
+    def __eq__(self, other: Any) -> bool:
+        """Indicate whether this collection object is equal to another"""
         if other is self:
             return True
         if not isinstance(other, RichCollectionBase):
@@ -1191,101 +1017,97 @@ class RichCollectionBase(CollectionBase):
                 return False
         return True
 
-    def __ne__(self, other):
+    def __ne__(self, other: Any) -> bool:
+        """Indicate whether this collection object is not equal to another"""
         return not self.__eq__(other)
 
     @synchronized
-    def flush(self):
-        """Flush bufferblocks to Keep."""
+    def flush(self) -> None:
+        """Upload any pending data to Keep"""
         for e in listvalues(self):
             e.flush()
 
 
 class Collection(RichCollectionBase):
-    """Represents the root of an Arvados Collection.
-
-    This class is threadsafe.  The root collection object, all subcollections
-    and files are protected by a single lock (i.e. each access locks the entire
-    collection).
-
-    Brief summary of
-    useful methods:
-
-    :To read an existing file:
-      `c.open("myfile", "r")`
-
-    :To write a new file:
-      `c.open("myfile", "w")`
-
-    :To determine if a file exists:
-      `c.find("myfile") is not None`
+    """Read and manipulate an Arvados collection
 
-    :To copy a file:
-      `c.copy("source", "dest")`
-
-    :To delete a file:
-      `c.remove("myfile")`
-
-    :To save to an existing collection record:
-      `c.save()`
-
-    :To save a new collection record:
-    `c.save_new()`
-
-    :To merge remote changes into this object:
-      `c.update()`
-
-    Must be associated with an API server Collection record (during
-    initialization, or using `save_new`) to use `save` or `update`
+    This class provides a high-level interface to create, read, and update
+    Arvados collections and their contents. Refer to the Arvados Python SDK
+    cookbook for [an introduction to using the Collection class][cookbook].
 
+    [cookbook]: https://doc.arvados.org/sdk/python/cookbook.html#working-with-collections
     """
 
-    def __init__(self, manifest_locator_or_text=None,
-                 api_client=None,
-                 keep_client=None,
-                 num_retries=10,
-                 parent=None,
-                 apiconfig=None,
-                 block_manager=None,
-                 replication_desired=None,
-                 storage_classes_desired=None,
-                 put_threads=None):
-        """Collection constructor.
-
-        :manifest_locator_or_text:
-          An Arvados collection UUID, portable data hash, raw manifest
-          text, or (if creating an empty collection) None.
-
-        :parent:
-          the parent Collection, may be None.
-
-        :apiconfig:
-          A dict containing keys for ARVADOS_API_HOST and ARVADOS_API_TOKEN.
-          Prefer this over supplying your own api_client and keep_client (except in testing).
-          Will use default config settings if not specified.
+    def __init__(self, manifest_locator_or_text: Optional[str]=None,
+                 api_client: Optional['arvados.api_resources.ArvadosAPIClient']=None,
+                 keep_client: Optional['arvados.keep.KeepClient']=None,
+                 num_retries: int=10,
+                 parent: Optional['Collection']=None,
+                 apiconfig: Optional[Mapping[str, str]]=None,
+                 block_manager: Optional['arvados.arvfile._BlockManager']=None,
+                 replication_desired: Optional[int]=None,
+                 storage_classes_desired: Optional[List[str]]=None,
+                 put_threads: Optional[int]=None):
+        """Initialize a Collection object
 
-        :api_client:
-          The API client object to use for requests.  If not specified, create one using `apiconfig`.
-
-        :keep_client:
-          the Keep client to use for requests.  If not specified, create one using `apiconfig`.
-
-        :num_retries:
-          the number of retries for API and Keep requests.
-
-        :block_manager:
-          the block manager to use.  If not specified, create one.
-
-        :replication_desired:
-          How many copies should Arvados maintain. If None, API server default
-          configuration applies. If not None, this value will also be used
-          for determining the number of block copies being written.
-
-        :storage_classes_desired:
-          A list of storage class names where to upload the data. If None,
-          the keep client is expected to store the data into the cluster's
-          default storage class(es).
+        Arguments:
 
+        * manifest_locator_or_text: str | None --- This string can contain a
+          collection manifest text, portable data hash, or UUID. When given a
+          portable data hash or UUID, this instance will load a collection
+          record from the API server. Otherwise, this instance will represent a
+          new collection without an API server record. The default value `None`
+          instantiates a new collection with an empty manifest.
+
+        * api_client: arvados.api_resources.ArvadosAPIClient | None --- The
+          Arvados API client object this instance uses to make requests. If
+          none is given, this instance creates its own client using the
+          settings from `apiconfig` (see below). If your client instantiates
+          many Collection objects, you can help limit memory utilization by
+          calling `arvados.api.api` to construct an
+          `arvados.safeapi.ThreadSafeApiCache`, and use that as the `api_client`
+          for every Collection.
+
+        * keep_client: arvados.keep.KeepClient | None --- The Keep client
+          object this instance uses to make requests. If none is given, this
+          instance creates its own client using its `api_client`.
+
+        * num_retries: int --- The number of times that client requests are
+          retried. Default 10.
+
+        * parent: arvados.collection.Collection | None --- The parent Collection
+          object of this instance, if any. This argument is primarily used by
+          other Collection methods; user client code shouldn't need to use it.
+
+        * apiconfig: Mapping[str, str] | None --- A mapping with entries for
+          `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and optionally
+          `ARVADOS_API_HOST_INSECURE`. When no `api_client` is provided, the
+          Collection object constructs one from these settings. If no
+          mapping is provided, calls `arvados.config.settings` to get these
+          parameters from user configuration.
+
+        * block_manager: arvados.arvfile._BlockManager | None --- The
+          _BlockManager object used by this instance to coordinate reading
+          and writing Keep data blocks. If none is given, this instance
+          constructs its own. This argument is primarily used by other
+          Collection methods; user client code shouldn't need to use it.
+
+        * replication_desired: int | None --- This controls both the value of
+          the `replication_desired` field on API collection records saved by
+          this class, as well as the number of Keep services that the object
+          writes new data blocks to. If none is given, uses the default value
+          configured for the cluster.
+
+        * storage_classes_desired: list[str] | None --- This controls both
+          the value of the `storage_classes_desired` field on API collection
+          records saved by this class, as well as selecting which specific
+          Keep services the object writes new data blocks to. If none is
+          given, defaults to an empty list.
+
+        * put_threads: int | None --- The number of threads to run
+          simultaneously to upload data blocks to Keep. This value is used when
+          building a new `block_manager`. It is unused when a `block_manager`
+          is provided.
         """
 
         if storage_classes_desired and type(storage_classes_desired) is not list:
@@ -1339,19 +1161,33 @@ class Collection(RichCollectionBase):
             except errors.SyntaxError as e:
                 raise errors.ArgumentError("Error processing manifest text: %s", str(e)) from None
 
-    def storage_classes_desired(self):
+    def storage_classes_desired(self) -> List[str]:
+        """Get this collection's `storage_classes_desired` value"""
         return self._storage_classes_desired or []
 
-    def root_collection(self):
+    def root_collection(self) -> 'Collection':
         return self
 
-    def get_properties(self):
+    def get_properties(self) -> Properties:
+        """Get this collection's properties
+
+        This method always returns a dict. If this collection object does not
+        have an associated API record, or that record does not have any
+        properties set, this method returns an empty dict.
+        """
         if self._api_response and self._api_response["properties"]:
             return self._api_response["properties"]
         else:
             return {}
 
-    def get_trash_at(self):
+    def get_trash_at(self) -> Optional[datetime.datetime]:
+        """Get this collection's `trash_at` field
+
+        This method parses the `trash_at` field of the collection's API
+        record and returns a datetime from it. If that field is not set, or
+        this collection object does not have an associated API record,
+        returns None.
+        """
         if self._api_response and self._api_response["trash_at"]:
             try:
                 return ciso8601.parse_datetime(self._api_response["trash_at"])
@@ -1360,21 +1196,57 @@ class Collection(RichCollectionBase):
         else:
             return None
 
-    def stream_name(self):
+    def stream_name(self) -> str:
         return "."
 
-    def writable(self):
+    def writable(self) -> bool:
         return True
 
     @synchronized
-    def known_past_version(self, modified_at_and_portable_data_hash):
+    def known_past_version(
+            self,
+            modified_at_and_portable_data_hash: Tuple[Optional[str], Optional[str]]
+    ) -> bool:
+        """Indicate whether an API record for this collection has been seen before
+
+        As this collection object loads records from the API server, it records
+        their `modified_at` and `portable_data_hash` fields. This method accepts
+        a 2-tuple with values for those fields, and returns `True` if the
+        combination was previously loaded.
+        """
         return modified_at_and_portable_data_hash in self._past_versions
 
     @synchronized
     @retry_method
-    def update(self, other=None, num_retries=None):
-        """Merge the latest collection on the API server with the current collection."""
+    def update(
+            self,
+            other: Optional['Collection']=None,
+            num_retries: Optional[int]=None,
+    ) -> None:
+        """Merge another collection's contents into this one
+
+        This method compares the manifest of this collection instance with
+        another, then updates this instance's manifest with changes from the
+        other, renaming files to flag conflicts where necessary.
+
+        When called without any arguments, this method reloads the collection's
+        API record, and updates this instance with any changes that have
+        appeared server-side. If this instance does not have a corresponding
+        API record, this method raises `arvados.errors.ArgumentError`.
+
+        Arguments:
+
+        * other: arvados.collection.Collection | None --- The collection
+          whose contents should be merged into this instance. When not
+          provided, this method reloads this collection's API record and
+          constructs a Collection object from it.  If this instance does not
+          have a corresponding API record, this method raises
+          `arvados.errors.ArgumentError`.
 
+        * num_retries: int | None --- The number of times to retry reloading
+          the collection's API record from the API server. If not specified,
+          uses the `num_retries` provided when this instance was constructed.
+        """
         if other is None:
             if self._manifest_locator is None:
                 raise errors.ArgumentError("`other` is None but collection does not have a manifest_locator uuid")
@@ -1467,32 +1339,65 @@ class Collection(RichCollectionBase):
         return self
 
     def __exit__(self, exc_type, exc_value, traceback):
-        """Support scoped auto-commit in a with: block."""
-        if exc_type is None:
+        """Exit a context with this collection instance
+
+        If no exception was raised inside the context block, and this
+        collection is writable and has a corresponding API record, that
+        record will be updated to match the state of this instance at the end
+        of the block.
+        """
+        if exc_type is None:
             if self.writable() and self._has_collection_uuid():
                 self.save()
         self.stop_threads()
 
-    def stop_threads(self):
+    def stop_threads(self) -> None:
+        """Stop background Keep upload/download threads"""
         if self._block_manager is not None:
             self._block_manager.stop_threads()
 
     @synchronized
-    def manifest_locator(self):
-        """Get the manifest locator, if any.
-
-        The manifest locator will be set when the collection is loaded from an
-        API server record or the portable data hash of a manifest.
-
-        The manifest locator will be None if the collection is newly created or
-        was created directly from manifest text.  The method `save_new()` will
-        assign a manifest locator.
-
+    def manifest_locator(self) -> Optional[str]:
+        """Get this collection's manifest locator, if any
+
+        * If this collection instance is associated with an API record with a
+          UUID, return that.
+        * Otherwise, if this collection instance was loaded from an API record
+          by portable data hash, return that.
+        * Otherwise, return `None`.
         """
         return self._manifest_locator
 
     @synchronized
-    def clone(self, new_parent=None, new_name=None, readonly=False, new_config=None):
+    def clone(
+            self,
+            new_parent: Optional['Collection']=None,
+            new_name: Optional[str]=None,
+            readonly: bool=False,
+            new_config: Optional[Mapping[str, str]]=None,
+    ) -> 'Collection':
+        """Create a Collection object with the same contents as this instance
+
+        This method creates a new Collection object with contents that match
+        this instance's. The new collection will not be associated with any API
+        record.
+
+        Arguments:
+
+        * new_parent: arvados.collection.Collection | None --- This value is
+          passed to the new Collection's constructor as the `parent`
+          argument.
+
+        * new_name: str | None --- This value is unused.
+
+        * readonly: bool --- If this value is true, this method constructs and
+          returns a `CollectionReader`. Otherwise, it returns a mutable
+          `Collection`. Default `False`.
+
+        * new_config: Mapping[str, str] | None --- This value is passed to the
+          new Collection's constructor as `apiconfig`. If no value is provided,
+          defaults to the configuration passed to this instance's constructor.
+        """
         if new_config is None:
             new_config = self._config
         if readonly:
@@ -1504,31 +1409,31 @@ class Collection(RichCollectionBase):
         return newcollection
 
     @synchronized
-    def api_response(self):
-        """Returns information about this Collection fetched from the API server.
-
-        If the Collection exists in Keep but not the API server, currently
-        returns None.  Future versions may provide a synthetic response.
+    def api_response(self) -> Optional[Dict[str, Any]]:
+        """Get this instance's associated API record
 
+        If this Collection instance has an associated API record, return it.
+        Otherwise, return `None`.
         """
         return self._api_response
 
-    def find_or_create(self, path, create_type):
-        """See `RichCollectionBase.find_or_create`"""
+    def find_or_create(
+            self,
+            path: str,
+            create_type: CreateType,
+    ) -> CollectionItem:
         if path == ".":
             return self
         else:
             return super(Collection, self).find_or_create(path[2:] if path.startswith("./") else path, create_type)
 
-    def find(self, path):
-        """See `RichCollectionBase.find`"""
+    def find(self, path: str) -> CollectionItem:
         if path == ".":
             return self
         else:
             return super(Collection, self).find(path[2:] if path.startswith("./") else path)
 
-    def remove(self, path, recursive=False):
-        """See `RichCollectionBase.remove`"""
+    def remove(self, path: str, recursive: bool=False) -> None:
         if path == ".":
             raise errors.ArgumentError("Cannot remove '.'")
         else:
@@ -1537,49 +1442,52 @@ class Collection(RichCollectionBase):
     @must_be_writable
     @synchronized
     @retry_method
-    def save(self,
-             properties=None,
-             storage_classes=None,
-             trash_at=None,
-             merge=True,
-             num_retries=None,
-             preserve_version=False):
-        """Save collection to an existing collection record.
-
-        Commit pending buffer blocks to Keep, merge with remote record (if
-        merge=True, the default), and update the collection record. Returns
-        the current manifest text.
-
-        Will raise AssertionError if not associated with a collection record on
-        the API server.  If you want to save a manifest to Keep only, see
-        `save_new()`.
-
-        :properties:
-          Additional properties of collection. This value will replace any existing
-          properties of collection.
-
-        :storage_classes:
-          Specify desirable storage classes to be used when writing data to Keep.
-
-        :trash_at:
-          A collection is *expiring* when it has a *trash_at* time in the future.
-          An expiring collection can be accessed as normal,
-          but is scheduled to be trashed automatically at the *trash_at* time.
-
-        :merge:
-          Update and merge remote changes before saving.  Otherwise, any
-          remote changes will be ignored and overwritten.
-
-        :num_retries:
-          Retry count on API calls (if None,  use the collection default)
-
-        :preserve_version:
-          If True, indicate that the collection content being saved right now
-          should be preserved in a version snapshot if the collection record is
-          updated in the future. Requires that the API server has
-          Collections.CollectionVersioning enabled, if not, setting this will
-          raise an exception.
+    def save(
+            self,
+            properties: Optional[Properties]=None,
+            storage_classes: Optional[StorageClasses]=None,
+            trash_at: Optional[datetime.datetime]=None,
+            merge: bool=True,
+            num_retries: Optional[int]=None,
+            preserve_version: bool=False,
+    ) -> str:
+        """Save collection to an existing API record
+
+        This method updates the instance's corresponding API record to match
+        the instance's state. If this instance does not have a corresponding API
+        record yet, raises `AssertionError`. (To create a new API record, use
+        `Collection.save_new`.) This method returns the saved collection
+        manifest.
 
+        Arguments:
+
+        * properties: dict[str, Any] | None --- If provided, the API record will
+          be updated with these properties. Note this will completely replace
+          any existing properties.
+
+        * storage_classes: list[str] | None --- If provided, the API record will
+          be updated with this value in the `storage_classes_desired` field.
+          This value will also be saved on the instance and used for any
+          changes that follow.
+
+        * trash_at: datetime.datetime | None --- If provided, the API record
+          will be updated with this value in the `trash_at` field.
+
+        * merge: bool --- If `True` (the default), this method will first
+          reload this collection's API record, and merge any new contents into
+          this instance before saving changes. See `Collection.update` for
+          details.
+
+        * num_retries: int | None --- The number of times to retry reloading
+          the collection's API record from the API server. If not specified,
+          uses the `num_retries` provided when this instance was constructed.
+
+        * preserve_version: bool --- This value will be passed to directly
+          to the underlying API call. If `True`, the Arvados API will
+          preserve the versions of this collection both immediately before
+          and after the update. If `True` when the API server is not
+          configured with collection versioning, this method raises
+          `arvados.errors.ArgumentError`.
         """
         if properties and type(properties) is not dict:
             raise errors.ArgumentError("properties must be dictionary type.")
@@ -1643,60 +1551,66 @@ class Collection(RichCollectionBase):
     @must_be_writable
     @synchronized
     @retry_method
-    def save_new(self, name=None,
-                 create_collection_record=True,
-                 owner_uuid=None,
-                 properties=None,
-                 storage_classes=None,
-                 trash_at=None,
-                 ensure_unique_name=False,
-                 num_retries=None,
-                 preserve_version=False):
-        """Save collection to a new collection record.
-
-        Commit pending buffer blocks to Keep and, when create_collection_record
-        is True (default), create a new collection record.  After creating a
-        new collection record, this Collection object will be associated with
-        the new record used by `save()`. Returns the current manifest text.
-
-        :name:
-          The collection name.
-
-        :create_collection_record:
-           If True, create a collection record on the API server.
-           If False, only commit blocks to Keep and return the manifest text.
-
-        :owner_uuid:
-          the user, or project uuid that will own this collection.
-          If None, defaults to the current user.
-
-        :properties:
-          Additional properties of collection. This value will replace any existing
-          properties of collection.
-
-        :storage_classes:
-          Specify desirable storage classes to be used when writing data to Keep.
-
-        :trash_at:
-          A collection is *expiring* when it has a *trash_at* time in the future.
-          An expiring collection can be accessed as normal,
-          but is scheduled to be trashed automatically at the *trash_at* time.
-
-        :ensure_unique_name:
-          If True, ask the API server to rename the collection
-          if it conflicts with a collection with the same name and owner.  If
-          False, a name conflict will result in an error.
-
-        :num_retries:
-          Retry count on API calls (if None,  use the collection default)
-
-        :preserve_version:
-          If True, indicate that the collection content being saved right now
-          should be preserved in a version snapshot if the collection record is
-          updated in the future. Requires that the API server has
-          Collections.CollectionVersioning enabled, if not, setting this will
-          raise an exception.
+    def save_new(
+            self,
+            name: Optional[str]=None,
+            create_collection_record: bool=True,
+            owner_uuid: Optional[str]=None,
+            properties: Optional[Properties]=None,
+            storage_classes: Optional[StorageClasses]=None,
+            trash_at: Optional[datetime.datetime]=None,
+            ensure_unique_name: bool=False,
+            num_retries: Optional[int]=None,
+            preserve_version: bool=False,
+    ):
+        """Save collection to a new API record
+
+        This method finishes uploading new data blocks and (optionally)
+        creates a new API collection record with the provided data. If a new
+        record is created, this instance becomes associated with that record
+        for future updates like `save()`. This method returns the saved
+        collection manifest.
+
+        Arguments:
+
+        * name: str | None --- The `name` field to use on the new collection
+          record. If not specified, a generic default name is generated.
+
+        * create_collection_record: bool --- If `True` (the default), creates a
+          collection record on the API server. If `False`, the method finishes
+          all data uploads and only returns the resulting collection manifest
+          without sending it to the API server.
+
+        * owner_uuid: str | None --- The `owner_uuid` field to use on the
+          new collection record.
 
+        * properties: dict[str, Any] | None --- The `properties` field to use on
+          the new collection record.
+
+        * storage_classes: list[str] | None --- The
+          `storage_classes_desired` field to use on the new collection record.
+
+        * trash_at: datetime.datetime | None --- The `trash_at` field to use
+          on the new collection record.
+
+        * ensure_unique_name: bool --- This value is passed directly to the
+          Arvados API when creating the collection record. If `True`, the API
+          server may modify the submitted `name` to ensure the collection's
+          `name`+`owner_uuid` combination is unique. If `False` (the default),
+          if a collection already exists with this same `name`+`owner_uuid`
+          combination, creating a collection record will raise a validation
+          error.
+
+        * num_retries: int | None --- The number of times to retry reloading
+          the collection's API record from the API server. If not specified,
+          uses the `num_retries` provided when this instance was constructed.
+
+        * preserve_version: bool --- This value will be passed to directly
+          to the underlying API call. If `True`, the Arvados API will
+          preserve the versions of this collection both immediately before
+          and after the update. If `True` when the API server is not
+          configured with collection versioning, this method raises
+          `arvados.errors.ArgumentError`.
         """
         if properties and type(properties) is not dict:
             raise errors.ArgumentError("properties must be dictionary type.")
@@ -1834,17 +1748,24 @@ class Collection(RichCollectionBase):
         self.set_committed(True)
 
     @synchronized
-    def notify(self, event, collection, name, item):
+    def notify(
+            self,
+            event: ChangeType,
+            collection: 'RichCollectionBase',
+            name: str,
+            item: CollectionItem,
+    ) -> None:
         if self._callback:
             self._callback(event, collection, name, item)
 
 
 class Subcollection(RichCollectionBase):
-    """This is a subdirectory within a collection that doesn't have its own API
-    server record.
-
-    Subcollection locking falls under the umbrella lock of its root collection.
+    """Read and manipulate a stream/directory within an Arvados collection
 
+    This class represents a single stream (like a directory) within an Arvados
+    `Collection`. It is returned by `Collection.find` and provides the same API.
+    Operations that work on the API collection record propagate to the parent
+    `Collection` object.
     """
 
     def __init__(self, parent, name):
@@ -1854,10 +1775,10 @@ class Subcollection(RichCollectionBase):
         self.name = name
         self.num_retries = parent.num_retries
 
-    def root_collection(self):
+    def root_collection(self) -> 'Collection':
         return self.parent.root_collection()
 
-    def writable(self):
+    def writable(self) -> bool:
         return self.root_collection().writable()
 
     def _my_api(self):
@@ -1869,11 +1790,15 @@ class Subcollection(RichCollectionBase):
     def _my_block_manager(self):
         return self.root_collection()._my_block_manager()
 
-    def stream_name(self):
+    def stream_name(self) -> str:
         return os.path.join(self.parent.stream_name(), self.name)
 
     @synchronized
-    def clone(self, new_parent, new_name):
+    def clone(
+            self,
+            new_parent: Optional['Collection']=None,
+            new_name: Optional[str]=None,
+    ) -> 'Subcollection':
         c = Subcollection(new_parent, new_name)
         c._clonefrom(self)
         return c
@@ -1900,11 +1825,11 @@ class Subcollection(RichCollectionBase):
 
 
 class CollectionReader(Collection):
-    """A read-only collection object.
-
-    Initialize from a collection UUID or portable data hash, or raw
-    manifest text.  See `Collection` constructor for detailed options.
+    """Read-only `Collection` subclass
 
+    This class will never create or update any API collection records. You can
+    use this class for additional code safety when you only need to read
+    existing collections.
     """
     def __init__(self, manifest_locator_or_text, *args, **kwargs):
         self._in_init = True
@@ -1918,7 +1843,7 @@ class CollectionReader(Collection):
         # all_streams() and all_files()
         self._streams = None
 
-    def writable(self):
+    def writable(self) -> bool:
         return self._in_init
 
     def _populate_streams(orig_func):
@@ -1935,16 +1860,10 @@ class CollectionReader(Collection):
             return orig_func(self, *args, **kwargs)
         return populate_streams_wrapper
 
+    @arvados.util._deprecated('3.0', 'Collection iteration')
     @_populate_streams
     def normalize(self):
-        """Normalize the streams returned by `all_streams`.
-
-        This method is kept for backwards compatability and only affects the
-        behavior of `all_streams()` and `all_files()`
-
-        """
-
-        # Rearrange streams
+        """Normalize the streams returned by `all_streams`"""
         streams = {}
         for s in self.all_streams():
             for f in s.all_files():
@@ -1971,3 +1890,423 @@ class CollectionReader(Collection):
         for s in self.all_streams():
             for f in s.all_files():
                 yield f
+
+
+class CollectionWriter(CollectionBase):
+    """Create a new collection from scratch
+
+    .. WARNING:: Deprecated
+       This class is deprecated. Prefer `arvados.collection.Collection`
+       instead.
+    """
+
+    @arvados.util._deprecated('3.0', 'arvados.collection.Collection')
+    def __init__(self, api_client=None, num_retries=0, replication=None):
+        """Instantiate a CollectionWriter.
+
+        CollectionWriter lets you build a new Arvados Collection from scratch.
+        Write files to it.  The CollectionWriter will upload data to Keep as
+        appropriate, and provide you with the Collection manifest text when
+        you're finished.
+
+        Arguments:
+        * api_client: The API client to use to look up Collections.  If not
+          provided, CollectionReader will build one from available Arvados
+          configuration.
+        * num_retries: The default number of times to retry failed
+          service requests.  Default 0.  You may change this value
+          after instantiation, but note those changes may not
+          propagate to related objects like the Keep client.
+        * replication: The number of copies of each block to store.
+          If this argument is None or not supplied, replication is
+          the server-provided default if available, otherwise 2.
+        """
+        self._api_client = api_client
+        self.num_retries = num_retries
+        self.replication = (2 if replication is None else replication)
+        self._keep_client = None
+        self._data_buffer = []
+        self._data_buffer_len = 0
+        self._current_stream_files = []
+        self._current_stream_length = 0
+        self._current_stream_locators = []
+        self._current_stream_name = '.'
+        self._current_file_name = None
+        self._current_file_pos = 0
+        self._finished_streams = []
+        self._close_file = None
+        self._queued_file = None
+        self._queued_dirents = deque()
+        self._queued_trees = deque()
+        self._last_open = None
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if exc_type is None:
+            self.finish()
+
+    def do_queued_work(self):
+        # The work queue consists of three pieces:
+        # * _queued_file: The file object we're currently writing to the
+        #   Collection.
+        # * _queued_dirents: Entries under the current directory
+        #   (_queued_trees[0]) that we want to write or recurse through.
+        #   This may contain files from subdirectories if
+        #   max_manifest_depth == 0 for this directory.
+        # * _queued_trees: Directories that should be written as separate
+        #   streams to the Collection.
+        # This function handles the smallest piece of work currently queued
+        # (current file, then current directory, then next directory) until
+        # no work remains.  The _work_THING methods each do a unit of work on
+        # THING.  _queue_THING methods add a THING to the work queue.
+        while True:
+            if self._queued_file:
+                self._work_file()
+            elif self._queued_dirents:
+                self._work_dirents()
+            elif self._queued_trees:
+                self._work_trees()
+            else:
+                break
+
+    def _work_file(self):
+        while True:
+            buf = self._queued_file.read(config.KEEP_BLOCK_SIZE)
+            if not buf:
+                break
+            self.write(buf)
+        self.finish_current_file()
+        if self._close_file:
+            self._queued_file.close()
+        self._close_file = None
+        self._queued_file = None
+
+    def _work_dirents(self):
+        path, stream_name, max_manifest_depth = self._queued_trees[0]
+        if stream_name != self.current_stream_name():
+            self.start_new_stream(stream_name)
+        while self._queued_dirents:
+            dirent = self._queued_dirents.popleft()
+            target = os.path.join(path, dirent)
+            if os.path.isdir(target):
+                self._queue_tree(target,
+                                 os.path.join(stream_name, dirent),
+                                 max_manifest_depth - 1)
+            else:
+                self._queue_file(target, dirent)
+                break
+        if not self._queued_dirents:
+            self._queued_trees.popleft()
+
+    def _work_trees(self):
+        path, stream_name, max_manifest_depth = self._queued_trees[0]
+        d = arvados.util.listdir_recursive(
+            path, max_depth = (None if max_manifest_depth == 0 else 0))
+        if d:
+            self._queue_dirents(stream_name, d)
+        else:
+            self._queued_trees.popleft()
+
+    def _queue_file(self, source, filename=None):
+        assert (self._queued_file is None), "tried to queue more than one file"
+        if not hasattr(source, 'read'):
+            source = open(source, 'rb')
+            self._close_file = True
+        else:
+            self._close_file = False
+        if filename is None:
+            filename = os.path.basename(source.name)
+        self.start_new_file(filename)
+        self._queued_file = source
+
+    def _queue_dirents(self, stream_name, dirents):
+        assert (not self._queued_dirents), "tried to queue more than one tree"
+        self._queued_dirents = deque(sorted(dirents))
+
+    def _queue_tree(self, path, stream_name, max_manifest_depth):
+        self._queued_trees.append((path, stream_name, max_manifest_depth))
+
+    def write_file(self, source, filename=None):
+        self._queue_file(source, filename)
+        self.do_queued_work()
+
+    def write_directory_tree(self,
+                             path, stream_name='.', max_manifest_depth=-1):
+        self._queue_tree(path, stream_name, max_manifest_depth)
+        self.do_queued_work()
+
+    def write(self, newdata):
+        if isinstance(newdata, bytes):
+            pass
+        elif isinstance(newdata, str):
+            newdata = newdata.encode()
+        elif hasattr(newdata, '__iter__'):
+            for s in newdata:
+                self.write(s)
+            return
+        self._data_buffer.append(newdata)
+        self._data_buffer_len += len(newdata)
+        self._current_stream_length += len(newdata)
+        while self._data_buffer_len >= config.KEEP_BLOCK_SIZE:
+            self.flush_data()
+
+    def open(self, streampath, filename=None):
+        """open(streampath[, filename]) -> file-like object
+
+        Pass in the path of a file to write to the Collection, either as a
+        single string or as two separate stream name and file name arguments.
+        This method returns a file-like object you can write to add it to the
+        Collection.
+
+        You may only have one file object from the Collection open at a time,
+        so be sure to close the object when you're done.  Using the object in
+        a with statement makes that easy:
+
+            with cwriter.open('./doc/page1.txt') as outfile:
+                outfile.write(page1_data)
+            with cwriter.open('./doc/page2.txt') as outfile:
+                outfile.write(page2_data)
+        """
+        if filename is None:
+            streampath, filename = split(streampath)
+        if self._last_open and not self._last_open.closed:
+            raise errors.AssertionError(
+                u"can't open '{}' when '{}' is still open".format(
+                    filename, self._last_open.name))
+        if streampath != self.current_stream_name():
+            self.start_new_stream(streampath)
+        self.set_current_file_name(filename)
+        self._last_open = _WriterFile(self, filename)
+        return self._last_open
+
+    def flush_data(self):
+        data_buffer = b''.join(self._data_buffer)
+        if data_buffer:
+            self._current_stream_locators.append(
+                self._my_keep().put(
+                    data_buffer[0:config.KEEP_BLOCK_SIZE],
+                    copies=self.replication))
+            self._data_buffer = [data_buffer[config.KEEP_BLOCK_SIZE:]]
+            self._data_buffer_len = len(self._data_buffer[0])
+
+    def start_new_file(self, newfilename=None):
+        self.finish_current_file()
+        self.set_current_file_name(newfilename)
+
+    def set_current_file_name(self, newfilename):
+        if re.search(r'[\t\n]', newfilename):
+            raise errors.AssertionError(
+                "Manifest filenames cannot contain whitespace: %s" %
+                newfilename)
+        elif re.search(r'\x00', newfilename):
+            raise errors.AssertionError(
+                "Manifest filenames cannot contain NUL characters: %s" %
+                newfilename)
+        self._current_file_name = newfilename
+
+    def current_file_name(self):
+        return self._current_file_name
+
+    def finish_current_file(self):
+        if self._current_file_name is None:
+            if self._current_file_pos == self._current_stream_length:
+                return
+            raise errors.AssertionError(
+                "Cannot finish an unnamed file " +
+                "(%d bytes at offset %d in '%s' stream)" %
+                (self._current_stream_length - self._current_file_pos,
+                 self._current_file_pos,
+                 self._current_stream_name))
+        self._current_stream_files.append([
+                self._current_file_pos,
+                self._current_stream_length - self._current_file_pos,
+                self._current_file_name])
+        self._current_file_pos = self._current_stream_length
+        self._current_file_name = None
+
+    def start_new_stream(self, newstreamname='.'):
+        self.finish_current_stream()
+        self.set_current_stream_name(newstreamname)
+
+    def set_current_stream_name(self, newstreamname):
+        if re.search(r'[\t\n]', newstreamname):
+            raise errors.AssertionError(
+                "Manifest stream names cannot contain whitespace: '%s'" %
+                (newstreamname))
+        self._current_stream_name = '.' if newstreamname=='' else newstreamname
+
+    def current_stream_name(self):
+        return self._current_stream_name
+
+    def finish_current_stream(self):
+        self.finish_current_file()
+        self.flush_data()
+        if not self._current_stream_files:
+            pass
+        elif self._current_stream_name is None:
+            raise errors.AssertionError(
+                "Cannot finish an unnamed stream (%d bytes in %d files)" %
+                (self._current_stream_length, len(self._current_stream_files)))
+        else:
+            if not self._current_stream_locators:
+                self._current_stream_locators.append(config.EMPTY_BLOCK_LOCATOR)
+            self._finished_streams.append([self._current_stream_name,
+                                           self._current_stream_locators,
+                                           self._current_stream_files])
+        self._current_stream_files = []
+        self._current_stream_length = 0
+        self._current_stream_locators = []
+        self._current_stream_name = None
+        self._current_file_pos = 0
+        self._current_file_name = None
+
+    def finish(self):
+        """Store the manifest in Keep and return its locator.
+
+        This is useful for storing manifest fragments (task outputs)
+        temporarily in Keep during a Crunch job.
+
+        In other cases you should make a collection instead, by
+        sending manifest_text() to the API server's "create
+        collection" endpoint.
+        """
+        return self._my_keep().put(self.manifest_text().encode(),
+                                   copies=self.replication)
+
+    def portable_data_hash(self):
+        stripped = self.stripped_manifest().encode()
+        return '{}+{}'.format(hashlib.md5(stripped).hexdigest(), len(stripped))
+
+    def manifest_text(self):
+        self.finish_current_stream()
+        manifest = ''
+
+        for stream in self._finished_streams:
+            if not re.search(r'^\.(/.*)?$', stream[0]):
+                manifest += './'
+            manifest += stream[0].replace(' ', '\\040')
+            manifest += ' ' + ' '.join(stream[1])
+            manifest += ' ' + ' '.join("%d:%d:%s" % (sfile[0], sfile[1], sfile[2].replace(' ', '\\040')) for sfile in stream[2])
+            manifest += "\n"
+
+        return manifest
+
+    def data_locators(self):
+        ret = []
+        for name, locators, files in self._finished_streams:
+            ret += locators
+        return ret
+
+    def save_new(self, name=None):
+        return self._api_client.collections().create(
+            ensure_unique_name=True,
+            body={
+                'name': name,
+                'manifest_text': self.manifest_text(),
+            }).execute(num_retries=self.num_retries)
+
+
+class ResumableCollectionWriter(CollectionWriter):
+    """CollectionWriter that can serialize internal state to disk
+
+    .. WARNING:: Deprecated
+       This class is deprecated. Prefer `arvados.collection.Collection`
+       instead.
+    """
+
+    STATE_PROPS = ['_current_stream_files', '_current_stream_length',
+                   '_current_stream_locators', '_current_stream_name',
+                   '_current_file_name', '_current_file_pos', '_close_file',
+                   '_data_buffer', '_dependencies', '_finished_streams',
+                   '_queued_dirents', '_queued_trees']
+
+    @arvados.util._deprecated('3.0', 'arvados.collection.Collection')
+    def __init__(self, api_client=None, **kwargs):
+        self._dependencies = {}
+        super(ResumableCollectionWriter, self).__init__(api_client, **kwargs)
+
+    @classmethod
+    def from_state(cls, state, *init_args, **init_kwargs):
+        # Try to build a new writer from scratch with the given state.
+        # If the state is not suitable to resume (because files have changed,
+        # been deleted, aren't predictable, etc.), raise a
+        # StaleWriterStateError.  Otherwise, return the initialized writer.
+        # The caller is responsible for calling writer.do_queued_work()
+        # appropriately after it's returned.
+        writer = cls(*init_args, **init_kwargs)
+        for attr_name in cls.STATE_PROPS:
+            attr_value = state[attr_name]
+            attr_class = getattr(writer, attr_name).__class__
+            # Coerce the value into the same type as the initial value, if
+            # needed.
+            if attr_class not in (type(None), attr_value.__class__):
+                attr_value = attr_class(attr_value)
+            setattr(writer, attr_name, attr_value)
+        # Check dependencies before we try to resume anything.
+        if any(KeepLocator(ls).permission_expired()
+               for ls in writer._current_stream_locators):
+            raise errors.StaleWriterStateError(
+                "locators include expired permission hint")
+        writer.check_dependencies()
+        if state['_current_file'] is not None:
+            path, pos = state['_current_file']
+            try:
+                writer._queued_file = open(path, 'rb')
+                writer._queued_file.seek(pos)
+            except IOError as error:
+                raise errors.StaleWriterStateError(
+                    u"failed to reopen active file {}: {}".format(path, error))
+        return writer
+
+    def check_dependencies(self):
+        for path, orig_stat in listitems(self._dependencies):
+            if not S_ISREG(orig_stat[ST_MODE]):
+                raise errors.StaleWriterStateError(u"{} not file".format(path))
+            try:
+                now_stat = tuple(os.stat(path))
+            except OSError as error:
+                raise errors.StaleWriterStateError(
+                    u"failed to stat {}: {}".format(path, error))
+            if ((not S_ISREG(now_stat[ST_MODE])) or
+                (orig_stat[ST_MTIME] != now_stat[ST_MTIME]) or
+                (orig_stat[ST_SIZE] != now_stat[ST_SIZE])):
+                raise errors.StaleWriterStateError(u"{} changed".format(path))
+
+    def dump_state(self, copy_func=lambda x: x):
+        state = {attr: copy_func(getattr(self, attr))
+                 for attr in self.STATE_PROPS}
+        if self._queued_file is None:
+            state['_current_file'] = None
+        else:
+            state['_current_file'] = (os.path.realpath(self._queued_file.name),
+                                      self._queued_file.tell())
+        return state
+
+    def _queue_file(self, source, filename=None):
+        try:
+            src_path = os.path.realpath(source)
+        except Exception:
+            raise errors.AssertionError(u"{} not a file path".format(source))
+        try:
+            path_stat = os.stat(src_path)
+        except OSError as stat_error:
+            path_stat = None
+        super(ResumableCollectionWriter, self)._queue_file(source, filename)
+        fd_stat = os.fstat(self._queued_file.fileno())
+        if not S_ISREG(fd_stat.st_mode):
+            # We won't be able to resume from this cache anyway, so don't
+            # worry about further checks.
+            self._dependencies[source] = tuple(fd_stat)
+        elif path_stat is None:
+            raise errors.AssertionError(
+                u"could not stat {}: {}".format(source, stat_error))
+        elif path_stat.st_ino != fd_stat.st_ino:
+            raise errors.AssertionError(
+                u"{} changed between open and stat calls".format(source))
+        else:
+            self._dependencies[src_path] = tuple(fd_stat)
+
+    def write(self, data):
+        if self._queued_file is None:
+            raise errors.AssertionError(
+                "resumable writer can't accept unsourced data")
+        return super(ResumableCollectionWriter, self).write(data)

commit 1fbd0ad57137ef23a6ec957746c05127ab8cc8c5
Author: Brett Smith <brett.smith at curii.com>
Date:   Wed Nov 29 08:54:10 2023 -0500

    Merge branch '21211-pysdk-annotations'
    
    Closes #21211.
    
    Arvados-DCO-1.1-Signed-off-by: Brett Smith <brett.smith at curii.com>

diff --git a/sdk/python/arvados/api.py b/sdk/python/arvados/api.py
index ca9f17f866..8a17e42fcb 100644
--- a/sdk/python/arvados/api.py
+++ b/sdk/python/arvados/api.py
@@ -9,12 +9,7 @@ niceties such as caching, X-Request-Id header for tracking, and more. The main
 client constructors are `api` and `api_from_config`.
 """
 
-from __future__ import absolute_import
-from future import standard_library
-standard_library.install_aliases()
-from builtins import range
 import collections
-import http.client
 import httplib2
 import json
 import logging
@@ -28,6 +23,14 @@ import threading
 import time
 import types
 
+from typing import (
+    Any,
+    Dict,
+    List,
+    Mapping,
+    Optional,
+)
+
 import apiclient
 import apiclient.http
 from apiclient import discovery as apiclient_discovery
@@ -152,7 +155,7 @@ def _new_http_error(cls, *args, **kwargs):
         errors.ApiError, *args, **kwargs)
 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
 
-def http_cache(data_type):
+def http_cache(data_type: str) -> cache.SafeHTTPCache:
     """Set up an HTTP file cache
 
     This function constructs and returns an `arvados.cache.SafeHTTPCache`
@@ -177,18 +180,18 @@ def http_cache(data_type):
     return cache.SafeHTTPCache(str(path), max_age=60*60*24*2)
 
 def api_client(
-        version,
-        discoveryServiceUrl,
-        token,
+        version: str,
+        discoveryServiceUrl: str,
+        token: str,
         *,
-        cache=True,
-        http=None,
-        insecure=False,
-        num_retries=10,
-        request_id=None,
-        timeout=5*60,
-        **kwargs,
-):
+        cache: bool=True,
+        http: Optional[httplib2.Http]=None,
+        insecure: bool=False,
+        num_retries: int=10,
+        request_id: Optional[str]=None,
+        timeout: int=5*60,
+        **kwargs: Any,
+) -> apiclient_discovery.Resource:
     """Build an Arvados API client
 
     This function returns a `googleapiclient.discovery.Resource` object
@@ -232,7 +235,6 @@ def api_client(
 
     Additional keyword arguments will be passed directly to
     `googleapiclient.discovery.build`.
-
     """
     if http is None:
         http = httplib2.Http(
@@ -294,12 +296,12 @@ def api_client(
     return svc
 
 def normalize_api_kwargs(
-        version=None,
-        discoveryServiceUrl=None,
-        host=None,
-        token=None,
-        **kwargs,
-):
+        version: Optional[str]=None,
+        discoveryServiceUrl: Optional[str]=None,
+        host: Optional[str]=None,
+        token: Optional[str]=None,
+        **kwargs: Any,
+) -> Dict[str, Any]:
     """Validate kwargs from `api` and build kwargs for `api_client`
 
     This method takes high-level keyword arguments passed to the `api`
@@ -352,7 +354,11 @@ def normalize_api_kwargs(
         **kwargs,
     }
 
-def api_kwargs_from_config(version=None, apiconfig=None, **kwargs):
+def api_kwargs_from_config(
+        version: Optional[str]=None,
+        apiconfig: Optional[Mapping[str, str]]=None,
+        **kwargs: Any
+) -> Dict[str, Any]:
     """Build `api_client` keyword arguments from configuration
 
     This function accepts a mapping with Arvados configuration settings like
@@ -395,9 +401,18 @@ def api_kwargs_from_config(version=None, apiconfig=None, **kwargs):
         **kwargs,
     )
 
-def api(version=None, cache=True, host=None, token=None, insecure=False,
-        request_id=None, timeout=5*60, *,
-        discoveryServiceUrl=None, **kwargs):
+def api(
+        version: Optional[str]=None,
+        cache: bool=True,
+        host: Optional[str]=None,
+        token: Optional[str]=None,
+        insecure: bool=False,
+        request_id: Optional[str]=None,
+        timeout: int=5*60,
+        *,
+        discoveryServiceUrl: Optional[str]=None,
+        **kwargs: Any,
+) -> 'arvados.safeapi.ThreadSafeApiCache':
     """Dynamically build an Arvados API client
 
     This function provides a high-level "do what I mean" interface to build an
@@ -449,7 +464,11 @@ def api(version=None, cache=True, host=None, token=None, insecure=False,
     from .safeapi import ThreadSafeApiCache
     return ThreadSafeApiCache({}, {}, kwargs, version)
 
-def api_from_config(version=None, apiconfig=None, **kwargs):
+def api_from_config(
+        version: Optional[str]=None,
+        apiconfig: Optional[Mapping[str, str]]=None,
+        **kwargs: Any
+) -> 'arvados.safeapi.ThreadSafeApiCache':
     """Build an Arvados API client from a configuration mapping
 
     This function builds an Arvados API client from a mapping with user
diff --git a/sdk/python/arvados/retry.py b/sdk/python/arvados/retry.py
index ea8a6f65af..e9e574f5df 100644
--- a/sdk/python/arvados/retry.py
+++ b/sdk/python/arvados/retry.py
@@ -15,21 +15,28 @@ It also provides utility functions for common operations with `RetryLoop`:
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from builtins import range
-from builtins import object
 import functools
 import inspect
 import pycurl
 import time
 
 from collections import deque
+from typing import (
+    Callable,
+    Generic,
+    Optional,
+    TypeVar,
+)
 
 import arvados.errors
 
 _HTTP_SUCCESSES = set(range(200, 300))
 _HTTP_CAN_RETRY = set([408, 409, 423, 500, 502, 503, 504])
 
-class RetryLoop(object):
+CT = TypeVar('CT', bound=Callable)
+T = TypeVar('T')
+
+class RetryLoop(Generic[T]):
     """Coordinate limited retries of code.
 
     `RetryLoop` coordinates a loop that runs until it records a
@@ -53,12 +60,12 @@ class RetryLoop(object):
       it doesn't succeed.  This means the loop body could run at most
       `num_retries + 1` times.
 
-    * success_check: Callable --- This is a function that will be called
-      each time the loop saves a result.  The function should return `True`
-      if the result indicates the code succeeded, `False` if it represents a
-      permanent failure, and `None` if it represents a temporary failure.
-      If no function is provided, the loop will end after any result is
-      saved.
+    * success_check: Callable[[T], bool | None] --- This is a function that
+      will be called each time the loop saves a result.  The function should
+      return `True` if the result indicates the code succeeded, `False` if
+      it represents a permanent failure, and `None` if it represents a
+      temporary failure.  If no function is provided, the loop will end
+      after any result is saved.
 
     * backoff_start: float --- The number of seconds that must pass before
       the loop's second iteration.  Default 0, which disables all waiting.
@@ -73,9 +80,15 @@ class RetryLoop(object):
     * max_wait: float --- Maximum number of seconds to wait between
       retries. Default 60.
     """
-    def __init__(self, num_retries, success_check=lambda r: True,
-                 backoff_start=0, backoff_growth=2, save_results=1,
-                 max_wait=60):
+    def __init__(
+            self,
+            num_retries: int,
+            success_check: Callable[[T], Optional[bool]]=lambda r: True,
+            backoff_start: float=0,
+            backoff_growth: float=2,
+            save_results: int=1,
+            max_wait: float=60
+    ) -> None:
         self.tries_left = num_retries + 1
         self.check_result = success_check
         self.backoff_wait = backoff_start
@@ -87,11 +100,11 @@ class RetryLoop(object):
         self._running = None
         self._success = None
 
-    def __iter__(self):
+    def __iter__(self) -> 'RetryLoop':
         """Return an iterator of retries."""
         return self
 
-    def running(self):
+    def running(self) -> Optional[bool]:
         """Return whether this loop is running.
 
         Returns `None` if the loop has never run, `True` if it is still running,
@@ -100,7 +113,7 @@ class RetryLoop(object):
         """
         return self._running and (self._success is None)
 
-    def __next__(self):
+    def __next__(self) -> int:
         """Record a loop attempt.
 
         If the loop is still running, decrements the number of tries left and
@@ -121,7 +134,7 @@ class RetryLoop(object):
         self.tries_left -= 1
         return self.tries_left
 
-    def save_result(self, result):
+    def save_result(self, result: T) -> None:
         """Record a loop result.
 
         Save the given result, and end the loop if it indicates
@@ -133,8 +146,7 @@ class RetryLoop(object):
 
         Arguments:
 
-        * result: Any --- The result from this loop attempt to check and
-        save.
+        * result: T --- The result from this loop attempt to check and save.
         """
         if not self.running():
             raise arvados.errors.AssertionError(
@@ -143,7 +155,7 @@ class RetryLoop(object):
         self._success = self.check_result(result)
         self._attempts += 1
 
-    def success(self):
+    def success(self) -> Optional[bool]:
         """Return the loop's end state.
 
         Returns `True` if the loop recorded a successful result, `False` if it
@@ -151,7 +163,7 @@ class RetryLoop(object):
         """
         return self._success
 
-    def last_result(self):
+    def last_result(self) -> T:
         """Return the most recent result the loop saved.
 
         Raises `arvados.errors.AssertionError` if called before any result has
@@ -163,7 +175,7 @@ class RetryLoop(object):
             raise arvados.errors.AssertionError(
                 "queried loop results before any were recorded")
 
-    def attempts(self):
+    def attempts(self) -> int:
         """Return the number of results that have been saved.
 
         This count includes all kinds of results: success, permanent failure,
@@ -171,7 +183,7 @@ class RetryLoop(object):
         """
         return self._attempts
 
-    def attempts_str(self):
+    def attempts_str(self) -> str:
         """Return a human-friendly string counting saved results.
 
         This method returns '1 attempt' or 'N attempts', where the number
@@ -183,7 +195,7 @@ class RetryLoop(object):
             return '{} attempts'.format(self._attempts)
 
 
-def check_http_response_success(status_code):
+def check_http_response_success(status_code: int) -> Optional[bool]:
     """Convert a numeric HTTP status code to a loop control flag.
 
     This method takes a numeric HTTP status code and returns `True` if
@@ -213,7 +225,7 @@ def check_http_response_success(status_code):
     else:
         return None  # Get well soon, server.
 
-def retry_method(orig_func):
+def retry_method(orig_func: CT) -> CT:
     """Provide a default value for a method's num_retries argument.
 
     This is a decorator for instance and class methods that accept a
diff --git a/sdk/python/arvados/safeapi.py b/sdk/python/arvados/safeapi.py
index 3ecc72a950..56b92e8f08 100644
--- a/sdk/python/arvados/safeapi.py
+++ b/sdk/python/arvados/safeapi.py
@@ -7,12 +7,15 @@ This module provides `ThreadSafeApiCache`, a thread-safe, API-compatible
 Arvados API client.
 """
 
-from __future__ import absolute_import
-
-from builtins import object
 import sys
 import threading
 
+from typing import (
+    Any,
+    Mapping,
+    Optional,
+)
+
 from . import config
 from . import keep
 from . import util
@@ -30,27 +33,31 @@ class ThreadSafeApiCache(object):
 
     Arguments:
 
-    apiconfig: Mapping[str, str] | None
-    : A mapping with entries for `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`,
-      and optionally `ARVADOS_API_HOST_INSECURE`. If not provided, uses
+    * apiconfig: Mapping[str, str] | None --- A mapping with entries for
+      `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and optionally
+      `ARVADOS_API_HOST_INSECURE`. If not provided, uses
       `arvados.config.settings` to get these parameters from user
       configuration.  You can pass an empty mapping to build the client
       solely from `api_params`.
 
-    keep_params: Mapping[str, Any]
-    : Keyword arguments used to construct an associated
-      `arvados.keep.KeepClient`.
+    * keep_params: Mapping[str, Any] --- Keyword arguments used to construct
+      an associated `arvados.keep.KeepClient`.
 
-    api_params: Mapping[str, Any]
-    : Keyword arguments used to construct each thread's API client. These
-      have the same meaning as in the `arvados.api.api` function.
+    * api_params: Mapping[str, Any] --- Keyword arguments used to construct
+      each thread's API client. These have the same meaning as in the
+      `arvados.api.api` function.
 
-    version: str | None
-    : A string naming the version of the Arvados API to use. If not specified,
-      the code will log a warning and fall back to 'v1'.
+    * version: str | None --- A string naming the version of the Arvados API
+      to use. If not specified, the code will log a warning and fall back to
+      `'v1'`.
     """
-
-    def __init__(self, apiconfig=None, keep_params={}, api_params={}, version=None):
+    def __init__(
+            self,
+            apiconfig: Optional[Mapping[str, str]]=None,
+            keep_params: Optional[Mapping[str, Any]]={},
+            api_params: Optional[Mapping[str, Any]]={},
+            version: Optional[str]=None,
+    ) -> None:
         if apiconfig or apiconfig is None:
             self._api_kwargs = api.api_kwargs_from_config(version, apiconfig, **api_params)
         else:
@@ -60,7 +67,7 @@ class ThreadSafeApiCache(object):
         self.local = threading.local()
         self.keep = keep.KeepClient(api_client=self, **keep_params)
 
-    def localapi(self):
+    def localapi(self) -> 'googleapiclient.discovery.Resource':
         try:
             client = self.local.api
         except AttributeError:
@@ -69,6 +76,6 @@ class ThreadSafeApiCache(object):
             self.local.api = client
         return client
 
-    def __getattr__(self, name):
+    def __getattr__(self, name: str) -> Any:
         # Proxy nonexistent attributes to the thread-local API client.
         return getattr(self.localapi(), name)

commit 3e5c9648ad4e9d5d9c320558b30a226a4702343c
Author: Brett Smith <brett.smith at curii.com>
Date:   Wed Nov 29 08:54:46 2023 -0500

    Merge branch '19830-pysdk-util-docs'
    
    Closes #19830.
    
    Arvados-DCO-1.1-Signed-off-by: Brett Smith <brett.smith at curii.com>

diff --git a/sdk/python/arvados/__init__.py b/sdk/python/arvados/__init__.py
index 21ca72c4bd..e90f381298 100644
--- a/sdk/python/arvados/__init__.py
+++ b/sdk/python/arvados/__init__.py
@@ -1,32 +1,30 @@
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: Apache-2.0
+"""Arvados Python SDK
+
+This module provides the entire Python SDK for Arvados. The most useful modules
+include:
+
+* arvados.api - After you `import arvados`, you can call `arvados.api.api` as
+  `arvados.api` to construct a client object.
+
+* arvados.collection - The `arvados.collection.Collection` class provides a
+  high-level interface to read and write collections. It coordinates sending
+  data to and from Keep, and synchronizing updates with the collection object.
+
+* arvados.util - Utility functions to use mostly in conjunction with the API
+  client object and the results it returns.
+
+Other submodules provide lower-level functionality.
+"""
 
-from __future__ import print_function
-from __future__ import absolute_import
-from future import standard_library
-standard_library.install_aliases()
-from builtins import object
-import bz2
-import fcntl
-import hashlib
-import http.client
-import httplib2
-import json
 import logging as stdliblog
 import os
-import pprint
-import re
-import string
 import sys
-import time
 import types
-import zlib
 
-if sys.version_info >= (3, 0):
-    from collections import UserDict
-else:
-    from UserDict import UserDict
+from collections import UserDict
 
 from .api import api, api_from_config, http_cache
 from .collection import CollectionReader, CollectionWriter, ResumableCollectionWriter
diff --git a/sdk/python/arvados/util.py b/sdk/python/arvados/util.py
index 88adc8879b..050c67f68d 100644
--- a/sdk/python/arvados/util.py
+++ b/sdk/python/arvados/util.py
@@ -1,10 +1,13 @@
 # Copyright (C) The Arvados Authors. All rights reserved.
 #
 # SPDX-License-Identifier: Apache-2.0
+"""Arvados utilities
 
-from __future__ import division
-from builtins import range
+This module provides functions and constants that are useful across a variety
+of Arvados resource types, or extend the Arvados API client (see `arvados.api`).
+"""
 
+import errno
 import fcntl
 import functools
 import hashlib
@@ -13,30 +16,63 @@ import os
 import random
 import re
 import subprocess
-import errno
 import sys
 import warnings
 
 import arvados.errors
 
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Iterator,
+    TypeVar,
+    Union,
+)
+
+T = TypeVar('T')
+
 HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
+"""Regular expression to match a hexadecimal string (case-insensitive)"""
 CR_UNCOMMITTED = 'Uncommitted'
+"""Constant `state` value for uncommited container requests"""
 CR_COMMITTED = 'Committed'
+"""Constant `state` value for committed container requests"""
 CR_FINAL = 'Final'
+"""Constant `state` value for finalized container requests"""
 
 keep_locator_pattern = re.compile(r'[0-9a-f]{32}\+[0-9]+(\+\S+)*')
+"""Regular expression to match any Keep block locator"""
 signed_locator_pattern = re.compile(r'[0-9a-f]{32}\+[0-9]+(\+\S+)*\+A\S+(\+\S+)*')
+"""Regular expression to match any Keep block locator with an access token hint"""
 portable_data_hash_pattern = re.compile(r'[0-9a-f]{32}\+[0-9]+')
+"""Regular expression to match any collection portable data hash"""
+manifest_pattern = re.compile(r'((\S+)( +[a-f0-9]{32}(\+[0-9]+)(\+\S+)*)+( +[0-9]+:[0-9]+:\S+)+$)+', flags=re.MULTILINE)
+"""Regular expression to match an Arvados collection manifest text"""
+keep_file_locator_pattern = re.compile(r'([0-9a-f]{32}\+[0-9]+)/(.*)')
+"""Regular expression to match a file path from a collection identified by portable data hash"""
+keepuri_pattern = re.compile(r'keep:([0-9a-f]{32}\+[0-9]+)/(.*)')
+"""Regular expression to match a `keep:` URI with a collection identified by portable data hash"""
+
 uuid_pattern = re.compile(r'[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}')
+"""Regular expression to match any Arvados object UUID"""
 collection_uuid_pattern = re.compile(r'[a-z0-9]{5}-4zz18-[a-z0-9]{15}')
+"""Regular expression to match any Arvados collection UUID"""
+container_uuid_pattern = re.compile(r'[a-z0-9]{5}-dz642-[a-z0-9]{15}')
+"""Regular expression to match any Arvados container UUID"""
 group_uuid_pattern = re.compile(r'[a-z0-9]{5}-j7d0g-[a-z0-9]{15}')
-user_uuid_pattern = re.compile(r'[a-z0-9]{5}-tpzed-[a-z0-9]{15}')
+"""Regular expression to match any Arvados group UUID"""
 link_uuid_pattern = re.compile(r'[a-z0-9]{5}-o0j2j-[a-z0-9]{15}')
+"""Regular expression to match any Arvados link UUID"""
+user_uuid_pattern = re.compile(r'[a-z0-9]{5}-tpzed-[a-z0-9]{15}')
+"""Regular expression to match any Arvados user UUID"""
 job_uuid_pattern = re.compile(r'[a-z0-9]{5}-8i9sb-[a-z0-9]{15}')
-container_uuid_pattern = re.compile(r'[a-z0-9]{5}-dz642-[a-z0-9]{15}')
-manifest_pattern = re.compile(r'((\S+)( +[a-f0-9]{32}(\+[0-9]+)(\+\S+)*)+( +[0-9]+:[0-9]+:\S+)+$)+', flags=re.MULTILINE)
-keep_file_locator_pattern = re.compile(r'([0-9a-f]{32}\+[0-9]+)/(.*)')
-keepuri_pattern = re.compile(r'keep:([0-9a-f]{32}\+[0-9]+)/(.*)')
+"""Regular expression to match any Arvados job UUID
+
+.. WARNING:: Deprecated
+   Arvados job resources are deprecated and will be removed in a future
+   release. Prefer the containers API instead.
+"""
 
 def _deprecated(version=None, preferred=None):
     """Mark a callable as deprecated in the SDK
@@ -47,12 +83,11 @@ def _deprecated(version=None, preferred=None):
     If the following arguments are given, they'll be included in the
     notices:
 
-    preferred: str | None
-    : The name of an alternative that users should use instead.
+    * preferred: str | None --- The name of an alternative that users should
+      use instead.
 
-    version: str | None
-    : The version of Arvados when the callable is scheduled to be
-      removed.
+    * version: str | None --- The version of Arvados when the callable is
+      scheduled to be removed.
     """
     if version is None:
         version = ''
@@ -91,6 +126,276 @@ def _deprecated(version=None, preferred=None):
         return deprecated_wrapper
     return deprecated_decorator
 
+def is_hex(s: str, *length_args: int) -> bool:
+    """Indicate whether a string is a hexadecimal number
+
+    This method returns true if all characters in the string are hexadecimal
+    digits. It is case-insensitive.
+
+    You can also pass optional length arguments to check that the string has
+    the expected number of digits. If you pass one integer, the string must
+    have that length exactly, otherwise the method returns False. If you
+    pass two integers, the string's length must fall within that minimum and
+    maximum (inclusive), otherwise the method returns False.
+
+    Arguments:
+
+    * s: str --- The string to check
+
+    * length_args: int --- Optional length limit(s) for the string to check
+    """
+    num_length_args = len(length_args)
+    if num_length_args > 2:
+        raise arvados.errors.ArgumentError(
+            "is_hex accepts up to 3 arguments ({} given)".format(1 + num_length_args))
+    elif num_length_args == 2:
+        good_len = (length_args[0] <= len(s) <= length_args[1])
+    elif num_length_args == 1:
+        good_len = (len(s) == length_args[0])
+    else:
+        good_len = True
+    return bool(good_len and HEX_RE.match(s))
+
+def keyset_list_all(
+        fn: Callable[..., 'arvados.api_resources.ArvadosAPIRequest'],
+        order_key: str="created_at",
+        num_retries: int=0,
+        ascending: bool=True,
+        **kwargs: Any,
+) -> Iterator[Dict[str, Any]]:
+    """Iterate all Arvados resources from an API list call
+
+    This method takes a method that represents an Arvados API list call, and
+    iterates the objects returned by the API server. It can make multiple API
+    calls to retrieve and iterate all objects available from the API server.
+
+    Arguments:
+
+    * fn: Callable[..., arvados.api_resources.ArvadosAPIRequest] --- A
+      function that wraps an Arvados API method that returns a list of
+      objects. If you have an Arvados API client named `arv`, examples
+      include `arv.collections().list` and `arv.groups().contents`. Note
+      that you should pass the function *without* calling it.
+
+    * order_key: str --- The name of the primary object field that objects
+      should be sorted by. This name is used to build an `order` argument
+      for `fn`. Default `'created_at'`.
+
+    * num_retries: int --- This argument is passed through to
+      `arvados.api_resources.ArvadosAPIRequest.execute` for each API call. See
+      that method's docstring for details. Default 0 (meaning API calls will
+      use the `num_retries` value set when the Arvados API client was
+      constructed).
+
+    * ascending: bool --- Used to build an `order` argument for `fn`. If True,
+      all fields will be sorted in `'asc'` (ascending) order. Otherwise, all
+      fields will be sorted in `'desc'` (descending) order.
+
+    Additional keyword arguments will be passed directly to `fn` for each API
+    call. Note that this function sets `count`, `limit`, and `order` as part of
+    its work.
+    """
+    pagesize = 1000
+    kwargs["limit"] = pagesize
+    kwargs["count"] = 'none'
+    asc = "asc" if ascending else "desc"
+    kwargs["order"] = ["%s %s" % (order_key, asc), "uuid %s" % asc]
+    other_filters = kwargs.get("filters", [])
+
+    try:
+        select = set(kwargs['select'])
+    except KeyError:
+        pass
+    else:
+        select.add(order_key)
+        select.add('uuid')
+        kwargs['select'] = list(select)
+
+    nextpage = []
+    tot = 0
+    expect_full_page = True
+    seen_prevpage = set()
+    seen_thispage = set()
+    lastitem = None
+    prev_page_all_same_order_key = False
+
+    while True:
+        kwargs["filters"] = nextpage+other_filters
+        items = fn(**kwargs).execute(num_retries=num_retries)
+
+        if len(items["items"]) == 0:
+            if prev_page_all_same_order_key:
+                nextpage = [[order_key, ">" if ascending else "<", lastitem[order_key]]]
+                prev_page_all_same_order_key = False
+                continue
+            else:
+                return
+
+        seen_prevpage = seen_thispage
+        seen_thispage = set()
+
+        for i in items["items"]:
+            # In cases where there's more than one record with the
+            # same order key, the result could include records we
+            # already saw in the last page.  Skip them.
+            if i["uuid"] in seen_prevpage:
+                continue
+            seen_thispage.add(i["uuid"])
+            yield i
+
+        firstitem = items["items"][0]
+        lastitem = items["items"][-1]
+
+        if firstitem[order_key] == lastitem[order_key]:
+            # Got a page where every item has the same order key.
+            # Switch to using uuid for paging.
+            nextpage = [[order_key, "=", lastitem[order_key]], ["uuid", ">" if ascending else "<", lastitem["uuid"]]]
+            prev_page_all_same_order_key = True
+        else:
+            # Start from the last order key seen, but skip the last
+            # known uuid to avoid retrieving the same row twice.  If
+            # there are multiple rows with the same order key it is
+            # still likely we'll end up retrieving duplicate rows.
+            # That's handled by tracking the "seen" rows for each page
+            # so they can be skipped if they show up on the next page.
+            nextpage = [[order_key, ">=" if ascending else "<=", lastitem[order_key]], ["uuid", "!=", lastitem["uuid"]]]
+            prev_page_all_same_order_key = False
+
+def ca_certs_path(fallback: T=httplib2.CA_CERTS) -> Union[str, T]:
+    """Return the path of the best available source of CA certificates
+
+    This function checks various known paths that provide trusted CA
+    certificates, and returns the first one that exists. It checks:
+
+    * the path in the `SSL_CERT_FILE` environment variable (used by OpenSSL)
+    * `/etc/arvados/ca-certificates.crt`, respected by all Arvados software
+    * `/etc/ssl/certs/ca-certificates.crt`, the default store on Debian-based
+      distributions
+    * `/etc/pki/tls/certs/ca-bundle.crt`, the default store on Red Hat-based
+      distributions
+
+    If none of these paths exist, this function returns the value of `fallback`.
+
+    Arguments:
+
+    * fallback: T --- The value to return if none of the known paths exist.
+      The default value is the certificate store of Mozilla's trusted CAs
+      included with the Python [certifi][] package.
+
+    [certifi]: https://pypi.org/project/certifi/
+    """
+    for ca_certs_path in [
+        # SSL_CERT_FILE and SSL_CERT_DIR are openssl overrides - note
+        # that httplib2 itself also supports HTTPLIB2_CA_CERTS.
+        os.environ.get('SSL_CERT_FILE'),
+        # Arvados specific:
+        '/etc/arvados/ca-certificates.crt',
+        # Debian:
+        '/etc/ssl/certs/ca-certificates.crt',
+        # Red Hat:
+        '/etc/pki/tls/certs/ca-bundle.crt',
+        ]:
+        if ca_certs_path and os.path.exists(ca_certs_path):
+            return ca_certs_path
+    return fallback
+
+def new_request_id() -> str:
+    """Return a random request ID
+
+    This function generates and returns a random string suitable for use as a
+    `X-Request-Id` header value in the Arvados API.
+    """
+    rid = "req-"
+    # 2**104 > 36**20 > 2**103
+    n = random.getrandbits(104)
+    for _ in range(20):
+        c = n % 36
+        if c < 10:
+            rid += chr(c+ord('0'))
+        else:
+            rid += chr(c+ord('a')-10)
+        n = n // 36
+    return rid
+
+def get_config_once(svc: 'arvados.api_resources.ArvadosAPIClient') -> Dict[str, Any]:
+    """Return an Arvados cluster's configuration, with caching
+
+    This function gets and returns the Arvados configuration from the API
+    server. It caches the result on the client object and reuses it on any
+    future calls.
+
+    Arguments:
+
+    * svc: arvados.api_resources.ArvadosAPIClient --- The Arvados API client
+      object to use to retrieve and cache the Arvados cluster configuration.
+    """
+    if not svc._rootDesc.get('resources').get('configs', False):
+        # Old API server version, no config export endpoint
+        return {}
+    if not hasattr(svc, '_cached_config'):
+        svc._cached_config = svc.configs().get().execute()
+    return svc._cached_config
+
+def get_vocabulary_once(svc: 'arvados.api_resources.ArvadosAPIClient') -> Dict[str, Any]:
+    """Return an Arvados cluster's vocabulary, with caching
+
+    This function gets and returns the Arvados vocabulary from the API
+    server. It caches the result on the client object and reuses it on any
+    future calls.
+
+    .. HINT:: Low-level method
+       This is a relatively low-level wrapper around the Arvados API. Most
+       users will prefer to use `arvados.vocabulary.load_vocabulary`.
+
+    Arguments:
+
+    * svc: arvados.api_resources.ArvadosAPIClient --- The Arvados API client
+      object to use to retrieve and cache the Arvados cluster vocabulary.
+    """
+    if not svc._rootDesc.get('resources').get('vocabularies', False):
+        # Old API server version, no vocabulary export endpoint
+        return {}
+    if not hasattr(svc, '_cached_vocabulary'):
+        svc._cached_vocabulary = svc.vocabularies().get().execute()
+    return svc._cached_vocabulary
+
+def trim_name(collectionname: str) -> str:
+    """Limit the length of a name to fit within Arvados API limits
+
+    This function ensures that a string is short enough to use as an object
+    name in the Arvados API, leaving room for text that may be added by the
+    `ensure_unique_name` argument. If the source name is short enough, it is
+    returned unchanged. Otherwise, this function returns a string with excess
+    characters removed from the middle of the source string and replaced with
+    an ellipsis.
+
+    Arguments:
+
+    * collectionname: str --- The desired source name
+    """
+    max_name_len = 254 - 28
+
+    if len(collectionname) > max_name_len:
+        over = len(collectionname) - max_name_len
+        split = int(max_name_len/2)
+        collectionname = collectionname[0:split] + "…" + collectionname[split+over:]
+
+    return collectionname
+
+ at _deprecated('3.0', 'arvados.util.keyset_list_all')
+def list_all(fn, num_retries=0, **kwargs):
+    # Default limit to (effectively) api server's MAX_LIMIT
+    kwargs.setdefault('limit', sys.maxsize)
+    items = []
+    offset = 0
+    items_available = sys.maxsize
+    while len(items) < items_available:
+        c = fn(offset=offset, **kwargs).execute(num_retries=num_retries)
+        items += c['items']
+        items_available = c['items_available']
+        offset = c['offset'] + len(c['items'])
+    return items
+
 @_deprecated('3.0')
 def clear_tmpdir(path=None):
     """
@@ -428,174 +733,3 @@ def listdir_recursive(dirname, base=None, max_depth=None):
         else:
             allfiles += [ent_base]
     return allfiles
-
-def is_hex(s, *length_args):
-    """is_hex(s[, length[, max_length]]) -> boolean
-
-    Return True if s is a string of hexadecimal digits.
-    If one length argument is given, the string must contain exactly
-    that number of digits.
-    If two length arguments are given, the string must contain a number of
-    digits between those two lengths, inclusive.
-    Return False otherwise.
-    """
-    num_length_args = len(length_args)
-    if num_length_args > 2:
-        raise arvados.errors.ArgumentError(
-            "is_hex accepts up to 3 arguments ({} given)".format(1 + num_length_args))
-    elif num_length_args == 2:
-        good_len = (length_args[0] <= len(s) <= length_args[1])
-    elif num_length_args == 1:
-        good_len = (len(s) == length_args[0])
-    else:
-        good_len = True
-    return bool(good_len and HEX_RE.match(s))
-
- at _deprecated('3.0', 'arvados.util.keyset_list_all')
-def list_all(fn, num_retries=0, **kwargs):
-    # Default limit to (effectively) api server's MAX_LIMIT
-    kwargs.setdefault('limit', sys.maxsize)
-    items = []
-    offset = 0
-    items_available = sys.maxsize
-    while len(items) < items_available:
-        c = fn(offset=offset, **kwargs).execute(num_retries=num_retries)
-        items += c['items']
-        items_available = c['items_available']
-        offset = c['offset'] + len(c['items'])
-    return items
-
-def keyset_list_all(fn, order_key="created_at", num_retries=0, ascending=True, **kwargs):
-    pagesize = 1000
-    kwargs["limit"] = pagesize
-    kwargs["count"] = 'none'
-    asc = "asc" if ascending else "desc"
-    kwargs["order"] = ["%s %s" % (order_key, asc), "uuid %s" % asc]
-    other_filters = kwargs.get("filters", [])
-
-    try:
-        select = set(kwargs['select'])
-    except KeyError:
-        pass
-    else:
-        select.add(order_key)
-        select.add('uuid')
-        kwargs['select'] = list(select)
-
-    nextpage = []
-    tot = 0
-    expect_full_page = True
-    seen_prevpage = set()
-    seen_thispage = set()
-    lastitem = None
-    prev_page_all_same_order_key = False
-
-    while True:
-        kwargs["filters"] = nextpage+other_filters
-        items = fn(**kwargs).execute(num_retries=num_retries)
-
-        if len(items["items"]) == 0:
-            if prev_page_all_same_order_key:
-                nextpage = [[order_key, ">" if ascending else "<", lastitem[order_key]]]
-                prev_page_all_same_order_key = False
-                continue
-            else:
-                return
-
-        seen_prevpage = seen_thispage
-        seen_thispage = set()
-
-        for i in items["items"]:
-            # In cases where there's more than one record with the
-            # same order key, the result could include records we
-            # already saw in the last page.  Skip them.
-            if i["uuid"] in seen_prevpage:
-                continue
-            seen_thispage.add(i["uuid"])
-            yield i
-
-        firstitem = items["items"][0]
-        lastitem = items["items"][-1]
-
-        if firstitem[order_key] == lastitem[order_key]:
-            # Got a page where every item has the same order key.
-            # Switch to using uuid for paging.
-            nextpage = [[order_key, "=", lastitem[order_key]], ["uuid", ">" if ascending else "<", lastitem["uuid"]]]
-            prev_page_all_same_order_key = True
-        else:
-            # Start from the last order key seen, but skip the last
-            # known uuid to avoid retrieving the same row twice.  If
-            # there are multiple rows with the same order key it is
-            # still likely we'll end up retrieving duplicate rows.
-            # That's handled by tracking the "seen" rows for each page
-            # so they can be skipped if they show up on the next page.
-            nextpage = [[order_key, ">=" if ascending else "<=", lastitem[order_key]], ["uuid", "!=", lastitem["uuid"]]]
-            prev_page_all_same_order_key = False
-
-def ca_certs_path(fallback=httplib2.CA_CERTS):
-    """Return the path of the best available CA certs source.
-
-    This function searches for various distribution sources of CA
-    certificates, and returns the first it finds.  If it doesn't find any,
-    it returns the value of `fallback` (httplib2's CA certs by default).
-    """
-    for ca_certs_path in [
-        # SSL_CERT_FILE and SSL_CERT_DIR are openssl overrides - note
-        # that httplib2 itself also supports HTTPLIB2_CA_CERTS.
-        os.environ.get('SSL_CERT_FILE'),
-        # Arvados specific:
-        '/etc/arvados/ca-certificates.crt',
-        # Debian:
-        '/etc/ssl/certs/ca-certificates.crt',
-        # Red Hat:
-        '/etc/pki/tls/certs/ca-bundle.crt',
-        ]:
-        if ca_certs_path and os.path.exists(ca_certs_path):
-            return ca_certs_path
-    return fallback
-
-def new_request_id():
-    rid = "req-"
-    # 2**104 > 36**20 > 2**103
-    n = random.getrandbits(104)
-    for _ in range(20):
-        c = n % 36
-        if c < 10:
-            rid += chr(c+ord('0'))
-        else:
-            rid += chr(c+ord('a')-10)
-        n = n // 36
-    return rid
-
-def get_config_once(svc):
-    if not svc._rootDesc.get('resources').get('configs', False):
-        # Old API server version, no config export endpoint
-        return {}
-    if not hasattr(svc, '_cached_config'):
-        svc._cached_config = svc.configs().get().execute()
-    return svc._cached_config
-
-def get_vocabulary_once(svc):
-    if not svc._rootDesc.get('resources').get('vocabularies', False):
-        # Old API server version, no vocabulary export endpoint
-        return {}
-    if not hasattr(svc, '_cached_vocabulary'):
-        svc._cached_vocabulary = svc.vocabularies().get().execute()
-    return svc._cached_vocabulary
-
-def trim_name(collectionname):
-    """
-    trim_name takes a record name (collection name, project name, etc)
-    and trims it to fit the 255 character name limit, with additional
-    space for the timestamp added by ensure_unique_name, by removing
-    excess characters from the middle and inserting an ellipse
-    """
-
-    max_name_len = 254 - 28
-
-    if len(collectionname) > max_name_len:
-        over = len(collectionname) - max_name_len
-        split = int(max_name_len/2)
-        collectionname = collectionname[0:split] + "…" + collectionname[split+over:]
-
-    return collectionname
diff --git a/sdk/python/tests/run_test_server.py b/sdk/python/tests/run_test_server.py
index eb2784c714..99c5af8a27 100644
--- a/sdk/python/tests/run_test_server.py
+++ b/sdk/python/tests/run_test_server.py
@@ -2,10 +2,6 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from __future__ import print_function
-from __future__ import division
-from builtins import str
-from builtins import range
 import argparse
 import atexit
 import errno
@@ -18,7 +14,6 @@ import shlex
 import shutil
 import signal
 import socket
-import string
 import subprocess
 import sys
 import tempfile
@@ -26,10 +21,7 @@ import time
 import unittest
 import yaml
 
-try:
-    from urllib.parse import urlparse
-except ImportError:
-    from urlparse import urlparse
+from urllib.parse import urlparse
 
 MY_DIRNAME = os.path.dirname(os.path.realpath(__file__))
 if __name__ == '__main__' and os.path.exists(

commit 29aeb9634b700eec2e5866782f08a09450a20dfd
Author: Brett Smith <brett.smith at curii.com>
Date:   Tue Nov 21 17:29:13 2023 -0500

    Merge branch '21132-api-resources-fixes'
    
    Closes #21132, #21136.
    
    Arvados-DCO-1.1-Signed-off-by: Brett Smith <brett.smith at curii.com>

diff --git a/sdk/python/discovery2pydoc.py b/sdk/python/discovery2pydoc.py
index 6ca3aafeb6..70a51371ac 100755
--- a/sdk/python/discovery2pydoc.py
+++ b/sdk/python/discovery2pydoc.py
@@ -77,12 +77,19 @@ If you work with this raw object, the keys of the dictionary are documented
 below, along with their types. The `items` key maps to a list of matching
 `{cls_name}` objects.
 '''
-_MODULE_PYDOC = '''Arvados API client documentation skeleton
-
-This module documents the methods and return types provided by the Arvados API
-client. Start with `ArvadosAPIClient`, which documents the methods available
-from the API client objects constructed by `arvados.api`. The implementation is
-generated dynamically at runtime when the client object is built.
+_MODULE_PYDOC = '''Arvados API client reference documentation
+
+This module provides reference documentation for the interface of the
+Arvados API client, including method signatures and type information for
+returned objects. However, the functions in `arvados.api` will return
+different classes at runtime that are generated dynamically from the Arvados
+API discovery document. The classes in this module do not have any
+implementation, and you should not instantiate them in your code.
+
+If you're just starting out, `ArvadosAPIClient` documents the methods
+available from the client object. From there, you can follow the trail into
+resource methods, request objects, and finally the data dictionaries returned
+by the API server.
 '''
 _SCHEMA_PYDOC = '''
 
@@ -95,25 +102,62 @@ to list the specific keys you need. Refer to the API documentation for details.
 '''
 
 _MODULE_PRELUDE = '''
+import googleapiclient.discovery
+import googleapiclient.http
+import httplib2
 import sys
+from typing import Any, Dict, Generic, List, Optional, TypeVar
 if sys.version_info < (3, 8):
-    from typing import Any
     from typing_extensions import TypedDict
 else:
-    from typing import Any, TypedDict
+    from typing import TypedDict
+
+# ST represents an API response type
+ST = TypeVar('ST', bound=TypedDict)
 '''
+_REQUEST_CLASS = '''
+class ArvadosAPIRequest(googleapiclient.http.HttpRequest, Generic[ST]):
+    """Generic API request object
+
+    When you call an API method in the Arvados Python SDK, it returns a
+    request object. You usually call `execute()` on this object to submit the
+    request to your Arvados API server and retrieve the response. `execute()`
+    will return the type of object annotated in the subscript of
+    `ArvadosAPIRequest`.
+    """
+
+    def execute(self, http: Optional[httplib2.Http]=None, num_retries: int=0) -> ST:
+        """Execute this request and return the response
+
+        Arguments:
+
+        * http: httplib2.Http | None --- The HTTP client object to use to
+          execute the request. If not specified, uses the HTTP client object
+          created with the API client object.
+
+        * num_retries: int --- The maximum number of times to retry this
+          request if the server returns a retryable failure. The API client
+          object also has a maximum number of retries specified when it is
+          instantiated (see `arvados.api.api_client`). This request is run
+          with the larger of that number and this argument. Default 0.
+        """
 
-_TYPE_MAP = {
+'''
+
+# Annotation represents a valid Python type annotation. Future development
+# could expand this to include other valid types like `type`.
+Annotation = str
+_TYPE_MAP: Mapping[str, Annotation] = {
     # Map the API's JavaScript-based type names to Python annotations.
     # Some of these may disappear after Arvados issue #19795 is fixed.
-    'Array': 'list',
-    'array': 'list',
+    'Array': 'List',
+    'array': 'List',
     'boolean': 'bool',
     # datetime fields are strings in ISO 8601 format.
     'datetime': 'str',
-    'Hash': 'dict[str, Any]',
+    'Hash': 'Dict[str, Any]',
     'integer': 'int',
-    'object': 'dict[str, Any]',
+    'object': 'Dict[str, Any]',
     'string': 'str',
     'text': 'str',
 }
@@ -197,9 +241,15 @@ class Parameter(inspect.Parameter):
 
 
 class Method:
-    def __init__(self, name: str, spec: Mapping[str, Any]) -> None:
+    def __init__(
+            self,
+            name: str,
+            spec: Mapping[str, Any],
+            annotate: Callable[[Annotation], Annotation]=str,
+    ) -> None:
         self.name = name
         self._spec = spec
+        self._annotate = annotate
         self._required_params = []
         self._optional_params = []
         for param_name, param_spec in spec['parameters'].items():
@@ -221,7 +271,8 @@ class Method:
         try:
             returns = get_type_annotation(self._spec['response']['$ref'])
         except KeyError:
-            returns = 'dict[str, Any]'
+            returns = 'Dict[str, Any]'
+        returns = self._annotate(returns)
         return inspect.Signature(parameters, return_annotation=returns)
 
     def doc(self, doc_slice: slice=slice(None)) -> str:
@@ -285,7 +336,7 @@ def document_resource(name: str, spec: Mapping[str, Any]) -> str:
     if class_name in _DEPRECATED_RESOURCES:
         docstring += _DEPRECATED_NOTICE
     methods = [
-        Method(key, meth_spec)
+        Method(key, meth_spec, 'ArvadosAPIRequest[{}]'.format)
         for key, meth_spec in spec['methods'].items()
         if key not in _ALIASED_METHODS
     ]
@@ -354,7 +405,11 @@ def main(arglist: Optional[Sequence[str]]=None) -> int:
     for name, resource_spec in resources:
         print(document_resource(name, resource_spec), file=args.out_file)
 
-    print('''class ArvadosAPIClient:''', file=args.out_file)
+    print(
+        _REQUEST_CLASS,
+        '''class ArvadosAPIClient(googleapiclient.discovery.Resource):''',
+        sep='\n', file=args.out_file,
+    )
     for name, _ in resources:
         class_name = classify_name(name)
         docstring = f"Return an instance of `{class_name}` to call methods via this client"

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list