From 3327da88478f3345993e0559cde86988279dfb4b Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sun, 6 Apr 2025 21:59:14 -0300 Subject: [PATCH] Refactor generate_sample_messages to be a generator and use numpy --- pyproject.toml | 1 + tests/test_ringbuf.py | 49 ++++++++----- tractor/_testing/samples.py | 135 ++++++++++++++++++++---------------- uv.lock | 50 +++++++++++++ 4 files changed, 159 insertions(+), 76 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fd67bff2..12f91482 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ dev = [ "pyperclip>=1.9.0", "prompt-toolkit>=3.0.50", "xonsh>=0.19.2", + "numpy>=2.2.4", # used for fast test sample gen ] # TODO, add these with sane versions; were originally in # `requirements-docs.txt`.. diff --git a/tests/test_ringbuf.py b/tests/test_ringbuf.py index d0a5e830..0fb70f92 100644 --- a/tests/test_ringbuf.py +++ b/tests/test_ringbuf.py @@ -13,7 +13,7 @@ from tractor.ipc import ( ) from tractor._testing.samples import ( generate_single_byte_msgs, - generate_sample_messages + RandomBytesGenerator ) @@ -76,18 +76,22 @@ async def child_write_shm( Attach to ringbuf and send all generated messages. ''' - sent_hash, msgs, _total_bytes = generate_sample_messages( + rng = RandomBytesGenerator( msg_amount, rand_min=rand_min, rand_max=rand_max, ) - await ctx.started(sent_hash) + await ctx.started() print('writer started') async with attach_to_ringbuf_sender(token, cleanup=False) as sender: - for msg in msgs: + for msg in rng: await sender.send(msg) + if rng.msgs_generated % rng.recommended_log_interval == 0: + print(f'wrote {rng.total_msgs} msgs') + print('writer exit') + return rng.hexdigest @pytest.mark.parametrize( @@ -149,12 +153,14 @@ def test_ringbuf( msg_amount=msg_amount, rand_min=rand_min, rand_max=rand_max, - ) as (_sctx, sent_hash), + ) as (sctx, _), + recv_p.open_context( child_read_shm, token=token, - ) as (rctx, _sent), + ) as (rctx, _), ): + sent_hash = await sctx.result() recvd_hash = await rctx.result() assert sent_hash == recvd_hash @@ -296,7 +302,7 @@ async def child_channel_sender( token_out: RBToken ): import random - _hash, msgs, _total_bytes = generate_sample_messages( + rng = RandomBytesGenerator( random.randint(msg_amount_min, msg_amount_max), rand_min=256, rand_max=1024, @@ -305,10 +311,14 @@ async def child_channel_sender( token_in, token_out ) as chan: - await ctx.started(msgs) - for msg in msgs: + await ctx.started() + for msg in rng: await chan.send(msg) + await chan.send(b'bye') + await chan.receive() + return rng.hexdigest + def test_channel(): @@ -323,7 +333,7 @@ def test_channel(): attach_to_ringbuf_channel(send_token, recv_token) as chan, tractor.open_nursery() as an ): - recv_p = await an.start_actor( + sender = await an.start_actor( 'test_ringbuf_transport_sender', enable_modules=[__name__], proc_kwargs={ @@ -331,19 +341,26 @@ def test_channel(): } ) async with ( - recv_p.open_context( + sender.open_context( child_channel_sender, msg_amount_min=msg_amount_min, msg_amount_max=msg_amount_max, token_in=recv_token, token_out=send_token - ) as (ctx, msgs), + ) as (ctx, _), ): - recv_msgs = [] + recvd_hash = hashlib.sha256() async for msg in chan: - recv_msgs.append(msg) + if msg == b'bye': + await chan.send(b'bye') + break - await recv_p.cancel_actor() - assert recv_msgs == msgs + recvd_hash.update(msg) + + sent_hash = await ctx.result() + + assert recvd_hash.hexdigest() == sent_hash + + await an.cancel() trio.run(main) diff --git a/tractor/_testing/samples.py b/tractor/_testing/samples.py index f8671332..fcf41dfa 100644 --- a/tractor/_testing/samples.py +++ b/tractor/_testing/samples.py @@ -1,84 +1,99 @@ -import os -import random import hashlib +import numpy as np def generate_single_byte_msgs(amount: int) -> bytes: ''' - Generate a byte instance of len `amount` with: - - ``` - byte_at_index(i) = (i % 10).encode() - ``` - - this results in constantly repeating sequences of: - - b'0123456789' + Generate a byte instance of length `amount` with repeating ASCII digits 0..9. ''' - return b''.join(str(i % 10).encode() for i in range(amount)) + # array [0, 1, 2, ..., amount-1], take mod 10 => [0..9], and map 0->'0'(48) + # up to 9->'9'(57). + arr = np.arange(amount, dtype=np.uint8) % 10 + # move into ascii space + arr += 48 + return arr.tobytes() -def generate_sample_messages( - amount: int, - rand_min: int = 0, - rand_max: int = 0, - silent: bool = False, -) -> tuple[str, list[bytes], int]: +class RandomBytesGenerator: ''' Generate bytes msgs for tests. - Messages will have the following format: + messages will have the following format: - ``` - b'[{i:08}]' + os.urandom(random.randint(rand_min, rand_max)) - ``` + b'[{i:08}]' + random_bytes so for message index 25: - b'[00000025]' + random_bytes + b'[00000025]' + random_bytes + + also generates sha256 hash of msgs. ''' - msgs = [] - size = 0 - log_interval = None - if not silent: - print(f'\ngenerating {amount} messages...') + def __init__( + self, + amount: int, + rand_min: int = 0, + rand_max: int = 0 + ): + if rand_max < rand_min: + raise ValueError('rand_max must be >= rand_min') - # calculate an apropiate log interval based on - # max message size - max_msg_size = 10 + rand_max + self._amount = amount + self._rand_min = rand_min + self._rand_max = rand_max + self._index = 0 + self._hasher = hashlib.sha256() + self._total_bytes = 0 + + self._lengths = np.random.randint( + rand_min, + rand_max + 1, + size=amount, + dtype=np.int32 + ) + + def __iter__(self): + return self + + def __next__(self) -> bytes: + if self._index == self._amount: + raise StopIteration + + header = f'[{self._index:08}]'.encode('utf-8') + + length = int(self._lengths[self._index]) + msg = header + np.random.bytes(length) + + self._hasher.update(msg) + self._total_bytes += length + self._index += 1 + + return msg + + @property + def hexdigest(self) -> str: + return self._hasher.hexdigest() + + @property + def total_bytes(self) -> int: + return self._total_bytes + + @property + def total_msgs(self) -> int: + return self._amount + + @property + def msgs_generated(self) -> int: + return self._index + + @property + def recommended_log_interval(self) -> int: + max_msg_size = 10 + self._rand_max if max_msg_size <= 32 * 1024: - log_interval = 10_000 + return 10_000 else: - log_interval = 1000 - - payload_hash = hashlib.sha256() - for i in range(amount): - msg = f'[{i:08}]'.encode('utf-8') - - if rand_max > 0: - msg += os.urandom( - random.randint(rand_min, rand_max)) - - size += len(msg) - - payload_hash.update(msg) - msgs.append(msg) - - if ( - not silent - and - i > 0 - and - i % log_interval == 0 - ): - print(f'{i} generated') - - if not silent: - print(f'done, {size:,} bytes in total') - - return payload_hash.hexdigest(), msgs, size + return 1000 diff --git a/uv.lock b/uv.lock index 76b22243..a865554d 100644 --- a/uv.lock +++ b/uv.lock @@ -180,6 +180,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/d8/f15b40611c2d5753d1abb0ca0da0c75348daf1252220e5dda2867bd81062/msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f", size = 187432 }, ] +[[package]] +name = "numpy" +version = "2.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/78/31103410a57bc2c2b93a3597340a8119588571f6a4539067546cb9a0bfac/numpy-2.2.4.tar.gz", hash = "sha256:9ba03692a45d3eef66559efe1d1096c4b9b75c0986b5dff5530c378fb8331d4f", size = 20270701 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/fb/09e778ee3a8ea0d4dc8329cca0a9c9e65fed847d08e37eba74cb7ed4b252/numpy-2.2.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e9e0a277bb2eb5d8a7407e14688b85fd8ad628ee4e0c7930415687b6564207a4", size = 21254989 }, + { url = "https://files.pythonhosted.org/packages/a2/0a/1212befdbecab5d80eca3cde47d304cad986ad4eec7d85a42e0b6d2cc2ef/numpy-2.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9eeea959168ea555e556b8188da5fa7831e21d91ce031e95ce23747b7609f8a4", size = 14425910 }, + { url = "https://files.pythonhosted.org/packages/2b/3e/e7247c1d4f15086bb106c8d43c925b0b2ea20270224f5186fa48d4fb5cbd/numpy-2.2.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bd3ad3b0a40e713fc68f99ecfd07124195333f1e689387c180813f0e94309d6f", size = 5426490 }, + { url = "https://files.pythonhosted.org/packages/5d/fa/aa7cd6be51419b894c5787a8a93c3302a1ed4f82d35beb0613ec15bdd0e2/numpy-2.2.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cf28633d64294969c019c6df4ff37f5698e8326db68cc2b66576a51fad634880", size = 6967754 }, + { url = "https://files.pythonhosted.org/packages/d5/ee/96457c943265de9fadeb3d2ffdbab003f7fba13d971084a9876affcda095/numpy-2.2.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fa8fa7697ad1646b5c93de1719965844e004fcad23c91228aca1cf0800044a1", size = 14373079 }, + { url = "https://files.pythonhosted.org/packages/c5/5c/ceefca458559f0ccc7a982319f37ed07b0d7b526964ae6cc61f8ad1b6119/numpy-2.2.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4162988a360a29af158aeb4a2f4f09ffed6a969c9776f8f3bdee9b06a8ab7e5", size = 16428819 }, + { url = "https://files.pythonhosted.org/packages/22/31/9b2ac8eee99e001eb6add9fa27514ef5e9faf176169057a12860af52704c/numpy-2.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:892c10d6a73e0f14935c31229e03325a7b3093fafd6ce0af704be7f894d95687", size = 15881470 }, + { url = "https://files.pythonhosted.org/packages/f0/dc/8569b5f25ff30484b555ad8a3f537e0225d091abec386c9420cf5f7a2976/numpy-2.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db1f1c22173ac1c58db249ae48aa7ead29f534b9a948bc56828337aa84a32ed6", size = 18218144 }, + { url = "https://files.pythonhosted.org/packages/5e/05/463c023a39bdeb9bb43a99e7dee2c664cb68d5bb87d14f92482b9f6011cc/numpy-2.2.4-cp311-cp311-win32.whl", hash = "sha256:ea2bb7e2ae9e37d96835b3576a4fa4b3a97592fbea8ef7c3587078b0068b8f09", size = 6606368 }, + { url = "https://files.pythonhosted.org/packages/8b/72/10c1d2d82101c468a28adc35de6c77b308f288cfd0b88e1070f15b98e00c/numpy-2.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:f7de08cbe5551911886d1ab60de58448c6df0f67d9feb7d1fb21e9875ef95e91", size = 12947526 }, + { url = "https://files.pythonhosted.org/packages/a2/30/182db21d4f2a95904cec1a6f779479ea1ac07c0647f064dea454ec650c42/numpy-2.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a7b9084668aa0f64e64bd00d27ba5146ef1c3a8835f3bd912e7a9e01326804c4", size = 20947156 }, + { url = "https://files.pythonhosted.org/packages/24/6d/9483566acfbda6c62c6bc74b6e981c777229d2af93c8eb2469b26ac1b7bc/numpy-2.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dbe512c511956b893d2dacd007d955a3f03d555ae05cfa3ff1c1ff6df8851854", size = 14133092 }, + { url = "https://files.pythonhosted.org/packages/27/f6/dba8a258acbf9d2bed2525cdcbb9493ef9bae5199d7a9cb92ee7e9b2aea6/numpy-2.2.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:bb649f8b207ab07caebba230d851b579a3c8711a851d29efe15008e31bb4de24", size = 5163515 }, + { url = "https://files.pythonhosted.org/packages/62/30/82116199d1c249446723c68f2c9da40d7f062551036f50b8c4caa42ae252/numpy-2.2.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:f34dc300df798742b3d06515aa2a0aee20941c13579d7a2f2e10af01ae4901ee", size = 6696558 }, + { url = "https://files.pythonhosted.org/packages/0e/b2/54122b3c6df5df3e87582b2e9430f1bdb63af4023c739ba300164c9ae503/numpy-2.2.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f7ac96b16955634e223b579a3e5798df59007ca43e8d451a0e6a50f6bfdfba", size = 14084742 }, + { url = "https://files.pythonhosted.org/packages/02/e2/e2cbb8d634151aab9528ef7b8bab52ee4ab10e076509285602c2a3a686e0/numpy-2.2.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f92084defa704deadd4e0a5ab1dc52d8ac9e8a8ef617f3fbb853e79b0ea3592", size = 16134051 }, + { url = "https://files.pythonhosted.org/packages/8e/21/efd47800e4affc993e8be50c1b768de038363dd88865920439ef7b422c60/numpy-2.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4e84a6283b36632e2a5b56e121961f6542ab886bc9e12f8f9818b3c266bfbb", size = 15578972 }, + { url = "https://files.pythonhosted.org/packages/04/1e/f8bb88f6157045dd5d9b27ccf433d016981032690969aa5c19e332b138c0/numpy-2.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:11c43995255eb4127115956495f43e9343736edb7fcdb0d973defd9de14cd84f", size = 17898106 }, + { url = "https://files.pythonhosted.org/packages/2b/93/df59a5a3897c1f036ae8ff845e45f4081bb06943039ae28a3c1c7c780f22/numpy-2.2.4-cp312-cp312-win32.whl", hash = "sha256:65ef3468b53269eb5fdb3a5c09508c032b793da03251d5f8722b1194f1790c00", size = 6311190 }, + { url = "https://files.pythonhosted.org/packages/46/69/8c4f928741c2a8efa255fdc7e9097527c6dc4e4df147e3cadc5d9357ce85/numpy-2.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:2aad3c17ed2ff455b8eaafe06bcdae0062a1db77cb99f4b9cbb5f4ecb13c5146", size = 12644305 }, + { url = "https://files.pythonhosted.org/packages/2a/d0/bd5ad792e78017f5decfb2ecc947422a3669a34f775679a76317af671ffc/numpy-2.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cf4e5c6a278d620dee9ddeb487dc6a860f9b199eadeecc567f777daace1e9e7", size = 20933623 }, + { url = "https://files.pythonhosted.org/packages/c3/bc/2b3545766337b95409868f8e62053135bdc7fa2ce630aba983a2aa60b559/numpy-2.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1974afec0b479e50438fc3648974268f972e2d908ddb6d7fb634598cdb8260a0", size = 14148681 }, + { url = "https://files.pythonhosted.org/packages/6a/70/67b24d68a56551d43a6ec9fe8c5f91b526d4c1a46a6387b956bf2d64744e/numpy-2.2.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:79bd5f0a02aa16808fcbc79a9a376a147cc1045f7dfe44c6e7d53fa8b8a79392", size = 5148759 }, + { url = "https://files.pythonhosted.org/packages/1c/8b/e2fc8a75fcb7be12d90b31477c9356c0cbb44abce7ffb36be39a0017afad/numpy-2.2.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:3387dd7232804b341165cedcb90694565a6015433ee076c6754775e85d86f1fc", size = 6683092 }, + { url = "https://files.pythonhosted.org/packages/13/73/41b7b27f169ecf368b52533edb72e56a133f9e86256e809e169362553b49/numpy-2.2.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f527d8fdb0286fd2fd97a2a96c6be17ba4232da346931d967a0630050dfd298", size = 14081422 }, + { url = "https://files.pythonhosted.org/packages/4b/04/e208ff3ae3ddfbafc05910f89546382f15a3f10186b1f56bd99f159689c2/numpy-2.2.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce43e386c16898b91e162e5baaad90c4b06f9dcbe36282490032cec98dc8ae7", size = 16132202 }, + { url = "https://files.pythonhosted.org/packages/fe/bc/2218160574d862d5e55f803d88ddcad88beff94791f9c5f86d67bd8fbf1c/numpy-2.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31504f970f563d99f71a3512d0c01a645b692b12a63630d6aafa0939e52361e6", size = 15573131 }, + { url = "https://files.pythonhosted.org/packages/a5/78/97c775bc4f05abc8a8426436b7cb1be806a02a2994b195945600855e3a25/numpy-2.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:81413336ef121a6ba746892fad881a83351ee3e1e4011f52e97fba79233611fd", size = 17894270 }, + { url = "https://files.pythonhosted.org/packages/b9/eb/38c06217a5f6de27dcb41524ca95a44e395e6a1decdc0c99fec0832ce6ae/numpy-2.2.4-cp313-cp313-win32.whl", hash = "sha256:f486038e44caa08dbd97275a9a35a283a8f1d2f0ee60ac260a1790e76660833c", size = 6308141 }, + { url = "https://files.pythonhosted.org/packages/52/17/d0dd10ab6d125c6d11ffb6dfa3423c3571befab8358d4f85cd4471964fcd/numpy-2.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:207a2b8441cc8b6a2a78c9ddc64d00d20c303d79fba08c577752f080c4007ee3", size = 12636885 }, + { url = "https://files.pythonhosted.org/packages/fa/e2/793288ede17a0fdc921172916efb40f3cbc2aa97e76c5c84aba6dc7e8747/numpy-2.2.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8120575cb4882318c791f839a4fd66161a6fa46f3f0a5e613071aae35b5dd8f8", size = 20961829 }, + { url = "https://files.pythonhosted.org/packages/3a/75/bb4573f6c462afd1ea5cbedcc362fe3e9bdbcc57aefd37c681be1155fbaa/numpy-2.2.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a761ba0fa886a7bb33c6c8f6f20213735cb19642c580a931c625ee377ee8bd39", size = 14161419 }, + { url = "https://files.pythonhosted.org/packages/03/68/07b4cd01090ca46c7a336958b413cdbe75002286295f2addea767b7f16c9/numpy-2.2.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:ac0280f1ba4a4bfff363a99a6aceed4f8e123f8a9b234c89140f5e894e452ecd", size = 5196414 }, + { url = "https://files.pythonhosted.org/packages/a5/fd/d4a29478d622fedff5c4b4b4cedfc37a00691079623c0575978d2446db9e/numpy-2.2.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:879cf3a9a2b53a4672a168c21375166171bc3932b7e21f622201811c43cdd3b0", size = 6709379 }, + { url = "https://files.pythonhosted.org/packages/41/78/96dddb75bb9be730b87c72f30ffdd62611aba234e4e460576a068c98eff6/numpy-2.2.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f05d4198c1bacc9124018109c5fba2f3201dbe7ab6e92ff100494f236209c960", size = 14051725 }, + { url = "https://files.pythonhosted.org/packages/00/06/5306b8199bffac2a29d9119c11f457f6c7d41115a335b78d3f86fad4dbe8/numpy-2.2.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f085ce2e813a50dfd0e01fbfc0c12bbe5d2063d99f8b29da30e544fb6483b8", size = 16101638 }, + { url = "https://files.pythonhosted.org/packages/fa/03/74c5b631ee1ded596945c12027649e6344614144369fd3ec1aaced782882/numpy-2.2.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:92bda934a791c01d6d9d8e038363c50918ef7c40601552a58ac84c9613a665bc", size = 15571717 }, + { url = "https://files.pythonhosted.org/packages/cb/dc/4fc7c0283abe0981e3b89f9b332a134e237dd476b0c018e1e21083310c31/numpy-2.2.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ee4d528022f4c5ff67332469e10efe06a267e32f4067dc76bb7e2cddf3cd25ff", size = 17879998 }, + { url = "https://files.pythonhosted.org/packages/e5/2b/878576190c5cfa29ed896b518cc516aecc7c98a919e20706c12480465f43/numpy-2.2.4-cp313-cp313t-win32.whl", hash = "sha256:05c076d531e9998e7e694c36e8b349969c56eadd2cdcd07242958489d79a7286", size = 6366896 }, + { url = "https://files.pythonhosted.org/packages/3e/05/eb7eec66b95cf697f08c754ef26c3549d03ebd682819f794cb039574a0a6/numpy-2.2.4-cp313-cp313t-win_amd64.whl", hash = "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", size = 12739119 }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -361,6 +409,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "greenback" }, + { name = "numpy" }, { name = "pexpect" }, { name = "prompt-toolkit" }, { name = "pyperclip" }, @@ -383,6 +432,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "greenback", specifier = ">=1.2.1,<2" }, + { name = "numpy", specifier = ">=2.2.4" }, { name = "pexpect", specifier = ">=4.9.0,<5" }, { name = "prompt-toolkit", specifier = ">=3.0.50" }, { name = "pyperclip", specifier = ">=1.9.0" },