kafka-python 2.1.2__tar.gz → 2.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. {kafka_python-2.1.2 → kafka_python-2.1.3}/CHANGES.md +22 -0
  2. {kafka_python-2.1.2 → kafka_python-2.1.3}/PKG-INFO +3 -2
  3. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/admin/client.py +1 -1
  4. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/client_async.py +8 -1
  5. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/cluster.py +1 -0
  6. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/conn.py +21 -4
  7. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/consumer/fetcher.py +64 -52
  8. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/consumer/group.py +18 -6
  9. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/consumer/subscription_state.py +68 -56
  10. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/coordinator/base.py +11 -9
  11. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/coordinator/consumer.py +12 -5
  12. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/producer/kafka.py +5 -0
  13. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/admin.py +2 -1
  14. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/api.py +8 -6
  15. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/api_versions.py +45 -1
  16. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/broker_api_versions.py +2 -0
  17. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/parser.py +7 -6
  18. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/util.py +24 -0
  19. kafka_python-2.1.3/kafka/version.py +1 -0
  20. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka_python.egg-info/PKG-INFO +3 -2
  21. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka_python.egg-info/SOURCES.txt +1 -0
  22. {kafka_python-2.1.2 → kafka_python-2.1.3}/pyproject.toml +1 -0
  23. kafka_python-2.1.3/test/test_consumer.py +52 -0
  24. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_consumer_group.py +7 -6
  25. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_consumer_integration.py +2 -2
  26. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_coordinator.py +1 -0
  27. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_fetcher.py +6 -6
  28. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_producer.py +1 -1
  29. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_sasl_integration.py +1 -1
  30. kafka_python-2.1.3/test/test_subscription_state.py +57 -0
  31. kafka_python-2.1.2/test/test_subscription_state.py → kafka_python-2.1.3/test/test_util.py +2 -3
  32. kafka_python-2.1.2/kafka/version.py +0 -1
  33. kafka_python-2.1.2/test/test_consumer.py +0 -26
  34. {kafka_python-2.1.2 → kafka_python-2.1.3}/AUTHORS.md +0 -0
  35. {kafka_python-2.1.2 → kafka_python-2.1.3}/LICENSE +0 -0
  36. {kafka_python-2.1.2 → kafka_python-2.1.3}/MANIFEST.in +0 -0
  37. {kafka_python-2.1.2 → kafka_python-2.1.3}/README.rst +0 -0
  38. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/__init__.py +0 -0
  39. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/admin/__init__.py +0 -0
  40. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/admin/acl_resource.py +0 -0
  41. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/admin/config_resource.py +0 -0
  42. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/admin/new_partitions.py +0 -0
  43. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/admin/new_topic.py +0 -0
  44. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/codec.py +0 -0
  45. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/consumer/__init__.py +0 -0
  46. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/coordinator/__init__.py +0 -0
  47. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/coordinator/assignors/__init__.py +0 -0
  48. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/coordinator/assignors/abstract.py +0 -0
  49. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/coordinator/assignors/range.py +0 -0
  50. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/coordinator/assignors/roundrobin.py +0 -0
  51. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/coordinator/assignors/sticky/__init__.py +0 -0
  52. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/coordinator/assignors/sticky/partition_movements.py +0 -0
  53. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/coordinator/assignors/sticky/sorted_set.py +0 -0
  54. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/coordinator/assignors/sticky/sticky_assignor.py +0 -0
  55. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/coordinator/heartbeat.py +0 -0
  56. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/coordinator/protocol.py +0 -0
  57. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/errors.py +0 -0
  58. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/future.py +0 -0
  59. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/__init__.py +0 -0
  60. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/compound_stat.py +0 -0
  61. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/dict_reporter.py +0 -0
  62. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/kafka_metric.py +0 -0
  63. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/measurable.py +0 -0
  64. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/measurable_stat.py +0 -0
  65. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/metric_config.py +0 -0
  66. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/metric_name.py +0 -0
  67. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/metrics.py +0 -0
  68. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/metrics_reporter.py +0 -0
  69. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/quota.py +0 -0
  70. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/stat.py +0 -0
  71. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/stats/__init__.py +0 -0
  72. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/stats/avg.py +0 -0
  73. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/stats/count.py +0 -0
  74. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/stats/histogram.py +0 -0
  75. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/stats/max_stat.py +0 -0
  76. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/stats/min_stat.py +0 -0
  77. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/stats/percentile.py +0 -0
  78. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/stats/percentiles.py +0 -0
  79. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/stats/rate.py +0 -0
  80. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/stats/sampled_stat.py +0 -0
  81. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/stats/sensor.py +0 -0
  82. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/metrics/stats/total.py +0 -0
  83. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/partitioner/__init__.py +0 -0
  84. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/partitioner/default.py +0 -0
  85. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/producer/__init__.py +0 -0
  86. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/producer/buffer.py +0 -0
  87. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/producer/future.py +0 -0
  88. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/producer/record_accumulator.py +0 -0
  89. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/producer/sender.py +0 -0
  90. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/__init__.py +0 -0
  91. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/abstract.py +0 -0
  92. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/commit.py +0 -0
  93. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/fetch.py +0 -0
  94. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/find_coordinator.py +0 -0
  95. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/frame.py +0 -0
  96. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/group.py +0 -0
  97. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/list_offsets.py +0 -0
  98. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/message.py +0 -0
  99. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/metadata.py +0 -0
  100. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/offset_for_leader_epoch.py +0 -0
  101. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/pickle.py +0 -0
  102. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/produce.py +0 -0
  103. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/sasl_authenticate.py +0 -0
  104. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/sasl_handshake.py +0 -0
  105. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/struct.py +0 -0
  106. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/protocol/types.py +0 -0
  107. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/record/__init__.py +0 -0
  108. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/record/_crc32c.py +0 -0
  109. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/record/abc.py +0 -0
  110. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/record/default_records.py +0 -0
  111. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/record/legacy_records.py +0 -0
  112. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/record/memory_records.py +0 -0
  113. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/record/util.py +0 -0
  114. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/sasl/__init__.py +0 -0
  115. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/sasl/abc.py +0 -0
  116. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/sasl/gssapi.py +0 -0
  117. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/sasl/msk.py +0 -0
  118. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/sasl/oauth.py +0 -0
  119. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/sasl/plain.py +0 -0
  120. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/sasl/scram.py +0 -0
  121. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/sasl/sspi.py +0 -0
  122. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/serializer/__init__.py +0 -0
  123. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/serializer/abstract.py +0 -0
  124. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/socks5_wrapper.py +0 -0
  125. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/structs.py +0 -0
  126. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/vendor/__init__.py +0 -0
  127. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/vendor/enum34.py +0 -0
  128. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/vendor/selectors34.py +0 -0
  129. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/vendor/six.py +0 -0
  130. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka/vendor/socketpair.py +0 -0
  131. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka_python.egg-info/dependency_links.txt +0 -0
  132. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka_python.egg-info/requires.txt +0 -0
  133. {kafka_python-2.1.2 → kafka_python-2.1.3}/kafka_python.egg-info/top_level.txt +0 -0
  134. {kafka_python-2.1.2 → kafka_python-2.1.3}/setup.cfg +0 -0
  135. {kafka_python-2.1.2 → kafka_python-2.1.3}/setup.py +0 -0
  136. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_acl_comparisons.py +0 -0
  137. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_admin.py +0 -0
  138. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_admin_integration.py +0 -0
  139. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_api_object_implementation.py +0 -0
  140. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_assignors.py +0 -0
  141. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_client_async.py +0 -0
  142. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_cluster.py +0 -0
  143. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_codec.py +0 -0
  144. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_conn.py +0 -0
  145. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_metrics.py +0 -0
  146. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_object_conversion.py +0 -0
  147. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_package.py +0 -0
  148. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_partition_movements.py +0 -0
  149. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_partitioner.py +0 -0
  150. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_protocol.py +0 -0
  151. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/test_sender.py +0 -0
  152. {kafka_python-2.1.2 → kafka_python-2.1.3}/test/testutil.py +0 -0
@@ -1,3 +1,25 @@
1
+ # 2.1.3 (Mar 25, 2025)
2
+
3
+ Fixes
4
+ * Fix crash when switching to closest compatible api_version in KafkaClient (#2567)
5
+ * Fix maximum version to send an OffsetFetchRequest in KafkaAdminClient (#2563)
6
+ * Return empty set from consumer.partitions_for_topic when topic not found (#2556)
7
+
8
+ Improvements
9
+ * KIP-511: Use ApiVersions v4 on initial connect w/ client_software_name + version (#2558)
10
+ * KIP-74: Manage assigned partition order in consumer (#2562)
11
+ * KIP-70: Auto-commit offsets on consumer.unsubscribe(), defer assignment changes to rejoin (#2560)
12
+ * Use SubscriptionType to track topics/pattern/user assignment (#2565)
13
+ * Add optional timeout_ms kwarg to consumer.close() (#2564)
14
+ * Move ensure_valid_topic_name to kafka.util; use in client and producer (#2561)
15
+
16
+ Testing
17
+ * Support KRaft / 4.0 brokers in tests (#2559)
18
+ * Test older pythons against 4.0 broker
19
+
20
+ Compatibility
21
+ * Add python 3.13 to compatibility list
22
+
1
23
  # 2.1.2 (Mar 17, 2025)
2
24
 
3
25
  Fixes
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: kafka-python
3
- Version: 2.1.2
3
+ Version: 2.1.3
4
4
  Summary: Pure Python client for Apache Kafka
5
5
  Author-email: Dana Powers <dana.powers@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/dpkp/kafka-python
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.9
21
21
  Classifier: Programming Language :: Python :: 3.10
22
22
  Classifier: Programming Language :: Python :: 3.11
23
23
  Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
24
25
  Classifier: Programming Language :: Python :: Implementation :: CPython
25
26
  Classifier: Programming Language :: Python :: Implementation :: PyPy
26
27
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
@@ -1496,7 +1496,7 @@ class KafkaAdminClient(object):
1496
1496
  A message future
1497
1497
  """
1498
1498
  version = self._client.api_version(OffsetFetchRequest, max_version=5)
1499
- if version <= 3:
1499
+ if version <= 5:
1500
1500
  if partitions is None:
1501
1501
  if version <= 1:
1502
1502
  raise ValueError(
@@ -27,7 +27,7 @@ from kafka.metrics.stats import Avg, Count, Rate
27
27
  from kafka.metrics.stats.rate import TimeUnit
28
28
  from kafka.protocol.broker_api_versions import BROKER_API_VERSIONS
29
29
  from kafka.protocol.metadata import MetadataRequest
30
- from kafka.util import Dict, WeakMethod
30
+ from kafka.util import Dict, WeakMethod, ensure_valid_topic_name
31
31
  # Although this looks unused, it actually monkey-patches socket.socketpair()
32
32
  # and should be left in as long as we're using socket.socketpair() in this file
33
33
  from kafka.vendor import socketpair # noqa: F401
@@ -276,6 +276,7 @@ class KafkaClient(object):
276
276
  if compatible_version:
277
277
  log.warning('Configured api_version %s not supported; using %s',
278
278
  self.config['api_version'], compatible_version)
279
+ self.config['api_version'] = compatible_version
279
280
  self._api_versions = BROKER_API_VERSIONS[compatible_version]
280
281
  else:
281
282
  raise Errors.UnrecognizedBrokerVersion(self.config['api_version'])
@@ -909,7 +910,13 @@ class KafkaClient(object):
909
910
 
910
911
  Returns:
911
912
  Future: resolves after metadata request/response
913
+
914
+ Raises:
915
+ TypeError: if topic is not a string
916
+ ValueError: if topic is invalid: must be chars (a-zA-Z0-9._-), and less than 250 length
912
917
  """
918
+ ensure_valid_topic_name(topic)
919
+
913
920
  if topic in self._topics:
914
921
  return Future().success(set(self._topics))
915
922
 
@@ -112,6 +112,7 @@ class ClusterMetadata(object):
112
112
 
113
113
  Returns:
114
114
  set: {partition (int), ...}
115
+ None if topic not found.
115
116
  """
116
117
  if topic not in self._partitions:
117
118
  return None
@@ -101,6 +101,10 @@ class BrokerConnection(object):
101
101
  server-side log entries that correspond to this client. Also
102
102
  submitted to GroupCoordinator for logging with respect to
103
103
  consumer group administration. Default: 'kafka-python-{version}'
104
+ client_software_name (str): Sent to kafka broker for KIP-511.
105
+ Default: 'kafka-python'
106
+ client_software_version (str): Sent to kafka broker for KIP-511.
107
+ Default: The kafka-python version (via kafka.version).
104
108
  reconnect_backoff_ms (int): The amount of time in milliseconds to
105
109
  wait before attempting to reconnect to a given host.
106
110
  Default: 50.
@@ -191,6 +195,8 @@ class BrokerConnection(object):
191
195
 
192
196
  DEFAULT_CONFIG = {
193
197
  'client_id': 'kafka-python-' + __version__,
198
+ 'client_software_name': 'kafka-python',
199
+ 'client_software_version': __version__,
194
200
  'node_id': 0,
195
201
  'request_timeout_ms': 30000,
196
202
  'reconnect_backoff_ms': 50,
@@ -242,7 +248,7 @@ class BrokerConnection(object):
242
248
  self._api_versions = None
243
249
  self._api_version = None
244
250
  self._check_version_idx = None
245
- self._api_versions_idx = 2
251
+ self._api_versions_idx = 4 # version of ApiVersionsRequest to try on first connect
246
252
  self._throttle_time = None
247
253
  self._socks5_proxy = None
248
254
 
@@ -538,7 +544,14 @@ class BrokerConnection(object):
538
544
  log.debug('%s: Using pre-configured api_version %s for ApiVersions', self, self._api_version)
539
545
  return True
540
546
  elif self._check_version_idx is None:
541
- request = ApiVersionsRequest[self._api_versions_idx]()
547
+ version = self._api_versions_idx
548
+ if version >= 3:
549
+ request = ApiVersionsRequest[version](
550
+ client_software_name=self.config['client_software_name'],
551
+ client_software_version=self.config['client_software_version'],
552
+ _tagged_fields={})
553
+ else:
554
+ request = ApiVersionsRequest[version]()
542
555
  future = Future()
543
556
  response = self._send(request, blocking=True, request_timeout_ms=(self.config['api_version_auto_timeout_ms'] * 0.8))
544
557
  response.add_callback(self._handle_api_versions_response, future)
@@ -573,11 +586,15 @@ class BrokerConnection(object):
573
586
 
574
587
  def _handle_api_versions_response(self, future, response):
575
588
  error_type = Errors.for_code(response.error_code)
576
- # if error_type i UNSUPPORTED_VERSION: retry w/ latest version from response
577
589
  if error_type is not Errors.NoError:
578
590
  future.failure(error_type())
579
591
  if error_type is Errors.UnsupportedVersionError:
580
592
  self._api_versions_idx -= 1
593
+ for api_key, min_version, max_version, *rest in response.api_versions:
594
+ # If broker provides a lower max_version, skip to that
595
+ if api_key == response.API_KEY:
596
+ self._api_versions_idx = min(self._api_versions_idx, max_version)
597
+ break
581
598
  if self._api_versions_idx >= 0:
582
599
  self._api_versions_future = None
583
600
  self.state = ConnectionStates.API_VERSIONS_SEND
@@ -587,7 +604,7 @@ class BrokerConnection(object):
587
604
  return
588
605
  self._api_versions = dict([
589
606
  (api_key, (min_version, max_version))
590
- for api_key, min_version, max_version in response.api_versions
607
+ for api_key, min_version, max_version, *rest in response.api_versions
591
608
  ])
592
609
  self._api_version = self._infer_broker_version_from_api_versions(self._api_versions)
593
610
  log.info('Broker version identified as %s', '.'.join(map(str, self._api_version)))
@@ -4,7 +4,6 @@ import collections
4
4
  import copy
5
5
  import itertools
6
6
  import logging
7
- import random
8
7
  import sys
9
8
  import time
10
9
 
@@ -57,7 +56,6 @@ class Fetcher(six.Iterator):
57
56
  'max_partition_fetch_bytes': 1048576,
58
57
  'max_poll_records': sys.maxsize,
59
58
  'check_crcs': True,
60
- 'iterator_refetch_records': 1, # undocumented -- interface may change
61
59
  'metric_group_prefix': 'consumer',
62
60
  'retry_backoff_ms': 100,
63
61
  'enable_incremental_fetch_sessions': True,
@@ -380,10 +378,13 @@ class Fetcher(six.Iterator):
380
378
  # as long as the partition is still assigned
381
379
  position = self._subscriptions.assignment[tp].position
382
380
  if part.next_fetch_offset == position.offset:
383
- part_records = part.take(max_records)
384
381
  log.debug("Returning fetched records at offset %d for assigned"
385
382
  " partition %s", position.offset, tp)
386
- drained[tp].extend(part_records)
383
+ part_records = part.take(max_records)
384
+ # list.extend([]) is a noop, but because drained is a defaultdict
385
+ # we should avoid initializing the default list unless there are records
386
+ if part_records:
387
+ drained[tp].extend(part_records)
387
388
  # We want to increment subscription position if (1) we're using consumer.poll(),
388
389
  # or (2) we didn't return any records (consumer iterator will update position
389
390
  # when each message is yielded). There may be edge cases where we re-fetch records
@@ -562,13 +563,11 @@ class Fetcher(six.Iterator):
562
563
  def _fetchable_partitions(self):
563
564
  fetchable = self._subscriptions.fetchable_partitions()
564
565
  # do not fetch a partition if we have a pending fetch response to process
566
+ discard = {fetch.topic_partition for fetch in self._completed_fetches}
565
567
  current = self._next_partition_records
566
- pending = copy.copy(self._completed_fetches)
567
568
  if current:
568
- fetchable.discard(current.topic_partition)
569
- for fetch in pending:
570
- fetchable.discard(fetch.topic_partition)
571
- return fetchable
569
+ discard.add(current.topic_partition)
570
+ return [tp for tp in fetchable if tp not in discard]
572
571
 
573
572
  def _create_fetch_requests(self):
574
573
  """Create fetch requests for all assigned partitions, grouped by node.
@@ -581,7 +580,7 @@ class Fetcher(six.Iterator):
581
580
  # create the fetch info as a dict of lists of partition info tuples
582
581
  # which can be passed to FetchRequest() via .items()
583
582
  version = self._client.api_version(FetchRequest, max_version=10)
584
- fetchable = collections.defaultdict(dict)
583
+ fetchable = collections.defaultdict(collections.OrderedDict)
585
584
 
586
585
  for partition in self._fetchable_partitions():
587
586
  node_id = self._client.cluster.leader_for_partition(partition)
@@ -695,10 +694,7 @@ class Fetcher(six.Iterator):
695
694
  for partition_data in partitions])
696
695
  metric_aggregator = FetchResponseMetricAggregator(self._sensors, partitions)
697
696
 
698
- # randomized ordering should improve balance for short-lived consumers
699
- random.shuffle(response.topics)
700
697
  for topic, partitions in response.topics:
701
- random.shuffle(partitions)
702
698
  for partition_data in partitions:
703
699
  tp = TopicPartition(topic, partition_data[0])
704
700
  fetch_offset = fetch_offsets[tp]
@@ -733,8 +729,6 @@ class Fetcher(six.Iterator):
733
729
  " since it is no longer fetchable", tp)
734
730
 
735
731
  elif error_type is Errors.NoError:
736
- self._subscriptions.assignment[tp].highwater = highwater
737
-
738
732
  # we are interested in this fetch only if the beginning
739
733
  # offset (of the *request*) matches the current consumed position
740
734
  # Note that the *response* may return a messageset that starts
@@ -748,30 +742,35 @@ class Fetcher(six.Iterator):
748
742
  return None
749
743
 
750
744
  records = MemoryRecords(completed_fetch.partition_data[-1])
751
- if records.has_next():
752
- log.debug("Adding fetched record for partition %s with"
753
- " offset %d to buffered record list", tp,
754
- position.offset)
755
- parsed_records = self.PartitionRecords(fetch_offset, tp, records,
756
- self.config['key_deserializer'],
757
- self.config['value_deserializer'],
758
- self.config['check_crcs'],
759
- completed_fetch.metric_aggregator)
760
- return parsed_records
761
- elif records.size_in_bytes() > 0:
762
- # we did not read a single message from a non-empty
763
- # buffer because that message's size is larger than
764
- # fetch size, in this case record this exception
765
- record_too_large_partitions = {tp: fetch_offset}
766
- raise RecordTooLargeError(
767
- "There are some messages at [Partition=Offset]: %s "
768
- " whose size is larger than the fetch size %s"
769
- " and hence cannot be ever returned."
770
- " Increase the fetch size, or decrease the maximum message"
771
- " size the broker will allow." % (
772
- record_too_large_partitions,
773
- self.config['max_partition_fetch_bytes']),
774
- record_too_large_partitions)
745
+ log.debug("Preparing to read %s bytes of data for partition %s with offset %d",
746
+ records.size_in_bytes(), tp, fetch_offset)
747
+ parsed_records = self.PartitionRecords(fetch_offset, tp, records,
748
+ self.config['key_deserializer'],
749
+ self.config['value_deserializer'],
750
+ self.config['check_crcs'],
751
+ completed_fetch.metric_aggregator,
752
+ self._on_partition_records_drain)
753
+ if not records.has_next() and records.size_in_bytes() > 0:
754
+ if completed_fetch.response_version < 3:
755
+ # Implement the pre KIP-74 behavior of throwing a RecordTooLargeException.
756
+ record_too_large_partitions = {tp: fetch_offset}
757
+ raise RecordTooLargeError(
758
+ "There are some messages at [Partition=Offset]: %s "
759
+ " whose size is larger than the fetch size %s"
760
+ " and hence cannot be ever returned. Please condier upgrading your broker to 0.10.1.0 or"
761
+ " newer to avoid this issue. Alternatively, increase the fetch size on the client (using"
762
+ " max_partition_fetch_bytes)" % (
763
+ record_too_large_partitions,
764
+ self.config['max_partition_fetch_bytes']),
765
+ record_too_large_partitions)
766
+ else:
767
+ # This should not happen with brokers that support FetchRequest/Response V3 or higher (i.e. KIP-74)
768
+ raise Errors.KafkaError("Failed to make progress reading messages at %s=%s."
769
+ " Received a non-empty fetch response from the server, but no"
770
+ " complete records were found." % (tp, fetch_offset))
771
+
772
+ if highwater >= 0:
773
+ self._subscriptions.assignment[tp].highwater = highwater
775
774
 
776
775
  elif error_type in (Errors.NotLeaderForPartitionError,
777
776
  Errors.ReplicaNotAvailableError,
@@ -805,14 +804,25 @@ class Fetcher(six.Iterator):
805
804
  if parsed_records is None:
806
805
  completed_fetch.metric_aggregator.record(tp, 0, 0)
807
806
 
808
- return None
807
+ if error_type is not Errors.NoError:
808
+ # we move the partition to the end if there was an error. This way, it's more likely that partitions for
809
+ # the same topic can remain together (allowing for more efficient serialization).
810
+ self._subscriptions.move_partition_to_end(tp)
811
+
812
+ return parsed_records
813
+
814
+ def _on_partition_records_drain(self, partition_records):
815
+ # we move the partition to the end if we received some bytes. This way, it's more likely that partitions
816
+ # for the same topic can remain together (allowing for more efficient serialization).
817
+ if partition_records.bytes_read > 0:
818
+ self._subscriptions.move_partition_to_end(partition_records.topic_partition)
809
819
 
810
820
  def close(self):
811
821
  if self._next_partition_records is not None:
812
822
  self._next_partition_records.drain()
813
823
 
814
824
  class PartitionRecords(object):
815
- def __init__(self, fetch_offset, tp, records, key_deserializer, value_deserializer, check_crcs, metric_aggregator):
825
+ def __init__(self, fetch_offset, tp, records, key_deserializer, value_deserializer, check_crcs, metric_aggregator, on_drain):
816
826
  self.fetch_offset = fetch_offset
817
827
  self.topic_partition = tp
818
828
  self.leader_epoch = -1
@@ -824,6 +834,7 @@ class Fetcher(six.Iterator):
824
834
  self.record_iterator = itertools.dropwhile(
825
835
  self._maybe_skip_record,
826
836
  self._unpack_records(tp, records, key_deserializer, value_deserializer))
837
+ self.on_drain = on_drain
827
838
 
828
839
  def _maybe_skip_record(self, record):
829
840
  # When fetching an offset that is in the middle of a
@@ -845,6 +856,7 @@ class Fetcher(six.Iterator):
845
856
  if self.record_iterator is not None:
846
857
  self.record_iterator = None
847
858
  self.metric_aggregator.record(self.topic_partition, self.bytes_read, self.records_read)
859
+ self.on_drain(self)
848
860
 
849
861
  def take(self, n=None):
850
862
  return list(itertools.islice(self.record_iterator, 0, n))
@@ -943,6 +955,13 @@ class FetchSessionHandler(object):
943
955
  self.session_partitions = {}
944
956
 
945
957
  def build_next(self, next_partitions):
958
+ """
959
+ Arguments:
960
+ next_partitions (dict): TopicPartition -> TopicPartitionState
961
+
962
+ Returns:
963
+ FetchRequestData
964
+ """
946
965
  if self.next_metadata.is_full:
947
966
  log.debug("Built full fetch %s for node %s with %s partition(s).",
948
967
  self.next_metadata, self.node_id, len(next_partitions))
@@ -965,8 +984,8 @@ class FetchSessionHandler(object):
965
984
  altered.add(tp)
966
985
 
967
986
  log.debug("Built incremental fetch %s for node %s. Added %s, altered %s, removed %s out of %s",
968
- self.next_metadata, self.node_id, added, altered, removed, self.session_partitions.keys())
969
- to_send = {tp: next_partitions[tp] for tp in (added | altered)}
987
+ self.next_metadata, self.node_id, added, altered, removed, self.session_partitions.keys())
988
+ to_send = collections.OrderedDict({tp: next_partitions[tp] for tp in next_partitions if tp in (added | altered)})
970
989
  return FetchRequestData(to_send, removed, self.next_metadata)
971
990
 
972
991
  def handle_response(self, response):
@@ -1106,18 +1125,11 @@ class FetchRequestData(object):
1106
1125
  @property
1107
1126
  def to_send(self):
1108
1127
  # Return as list of [(topic, [(partition, ...), ...]), ...]
1109
- # so it an be passed directly to encoder
1128
+ # so it can be passed directly to encoder
1110
1129
  partition_data = collections.defaultdict(list)
1111
1130
  for tp, partition_info in six.iteritems(self._to_send):
1112
1131
  partition_data[tp.topic].append(partition_info)
1113
- # As of version == 3 partitions will be returned in order as
1114
- # they are requested, so to avoid starvation with
1115
- # `fetch_max_bytes` option we need this shuffle
1116
- # NOTE: we do have partition_data in random order due to usage
1117
- # of unordered structures like dicts, but that does not
1118
- # guarantee equal distribution, and starting in Python3.6
1119
- # dicts retain insert order.
1120
- return random.sample(list(partition_data.items()), k=len(partition_data))
1132
+ return list(partition_data.items())
1121
1133
 
1122
1134
  @property
1123
1135
  def to_forget(self):
@@ -444,8 +444,15 @@ class KafkaConsumer(six.Iterator):
444
444
  no rebalance operation triggered when group membership or cluster
445
445
  and topic metadata change.
446
446
  """
447
- self._subscription.assign_from_user(partitions)
448
- self._client.set_topics([tp.topic for tp in partitions])
447
+ if not partitions:
448
+ self.unsubscribe()
449
+ else:
450
+ # make sure the offsets of topic partitions the consumer is unsubscribing from
451
+ # are committed since there will be no following rebalance
452
+ self._coordinator.maybe_auto_commit_offsets_now()
453
+ self._subscription.assign_from_user(partitions)
454
+ self._client.set_topics([tp.topic for tp in partitions])
455
+ log.debug("Subscribed to partition(s): %s", partitions)
449
456
 
450
457
  def assignment(self):
451
458
  """Get the TopicPartitions currently assigned to this consumer.
@@ -463,19 +470,21 @@ class KafkaConsumer(six.Iterator):
463
470
  """
464
471
  return self._subscription.assigned_partitions()
465
472
 
466
- def close(self, autocommit=True):
473
+ def close(self, autocommit=True, timeout_ms=None):
467
474
  """Close the consumer, waiting indefinitely for any needed cleanup.
468
475
 
469
476
  Keyword Arguments:
470
477
  autocommit (bool): If auto-commit is configured for this consumer,
471
478
  this optional flag causes the consumer to attempt to commit any
472
479
  pending consumed offsets prior to close. Default: True
480
+ timeout_ms (num, optional): Milliseconds to wait for auto-commit.
481
+ Default: None
473
482
  """
474
483
  if self._closed:
475
484
  return
476
485
  log.debug("Closing the KafkaConsumer.")
477
486
  self._closed = True
478
- self._coordinator.close(autocommit=autocommit)
487
+ self._coordinator.close(autocommit=autocommit, timeout_ms=timeout_ms)
479
488
  self._metrics.close()
480
489
  self._client.close()
481
490
  try:
@@ -634,7 +643,7 @@ class KafkaConsumer(six.Iterator):
634
643
  if partitions is None:
635
644
  self._fetch_all_topic_metadata()
636
645
  partitions = cluster.partitions_for_topic(topic)
637
- return partitions
646
+ return partitions or set()
638
647
 
639
648
  def poll(self, timeout_ms=0, max_records=None, update_offsets=True):
640
649
  """Fetch data from assigned topics / partitions.
@@ -959,8 +968,11 @@ class KafkaConsumer(six.Iterator):
959
968
 
960
969
  def unsubscribe(self):
961
970
  """Unsubscribe from all topics and clear all assigned partitions."""
971
+ # make sure the offsets of topic partitions the consumer is unsubscribing from
972
+ # are committed since there will be no following rebalance
973
+ self._coordinator.maybe_auto_commit_offsets_now()
962
974
  self._subscription.unsubscribe()
963
- self._coordinator.close()
975
+ self._coordinator.maybe_leave_group()
964
976
  self._client.cluster.need_all_topic_metadata = False
965
977
  self._client.set_topics([])
966
978
  log.debug("Unsubscribed all topics or patterns and assigned partitions")