mirror of https://github.com/nucypher/nucypher.git
Move PRE examples to their own subfolder.
Update .gitignore appropriately.pull/3258/head
parent
99d2bd86ae
commit
8ac2af0272
|
@ -27,10 +27,10 @@ chains
|
||||||
.ethash
|
.ethash
|
||||||
nucypher_cli/examples/examples-runtime-cruft/*
|
nucypher_cli/examples/examples-runtime-cruft/*
|
||||||
nucypher_cli/examples/finnegans-wake.txt
|
nucypher_cli/examples/finnegans-wake.txt
|
||||||
examples/heartbeat_demo/*.json
|
examples/pre/heartbeat_demo/*.json
|
||||||
examples/heartbeat_demo/*.msgpack
|
examples/pre/heartbeat_demo/*.msgpack
|
||||||
examples/heartbeat_demo/doctor-files/
|
examples/pre/heartbeat_demo/doctor-files/
|
||||||
examples/heartbeat_demo/alicia-files/
|
examples/pre/heartbeat_demo/alicia-files/
|
||||||
mypy_reports/
|
mypy_reports/
|
||||||
reports/
|
reports/
|
||||||
test-*
|
test-*
|
||||||
|
|
|
@ -17,27 +17,26 @@ from nucypher.utilities.logging import GlobalLoggerSettings
|
||||||
# Boring setup stuff #
|
# Boring setup stuff #
|
||||||
######################
|
######################
|
||||||
|
|
||||||
LOG_LEVEL = 'info'
|
LOG_LEVEL = "info"
|
||||||
GlobalLoggerSettings.set_log_level(log_level_name=LOG_LEVEL)
|
GlobalLoggerSettings.set_log_level(log_level_name=LOG_LEVEL)
|
||||||
GlobalLoggerSettings.start_console_logging()
|
GlobalLoggerSettings.start_console_logging()
|
||||||
|
|
||||||
BOOK_PATH = Path('finnegans-wake-excerpt.txt')
|
BOOK_PATH = Path("finnegans-wake-excerpt.txt")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
# Replace with ethereum RPC endpoint
|
# Replace with ethereum RPC endpoint
|
||||||
L1_PROVIDER = os.environ['DEMO_L1_PROVIDER_URI']
|
L1_PROVIDER = os.environ["DEMO_L1_PROVIDER_URI"]
|
||||||
L2_PROVIDER = os.environ['DEMO_L2_PROVIDER_URI']
|
L2_PROVIDER = os.environ["DEMO_L2_PROVIDER_URI"]
|
||||||
|
|
||||||
# Replace with wallet filepath.
|
# Replace with wallet filepath.
|
||||||
WALLET_FILEPATH = os.environ['DEMO_L2_WALLET_FILEPATH']
|
WALLET_FILEPATH = os.environ["DEMO_L2_WALLET_FILEPATH"]
|
||||||
SIGNER_URI = f'keystore://{WALLET_FILEPATH}'
|
SIGNER_URI = f"keystore://{WALLET_FILEPATH}"
|
||||||
|
|
||||||
# Replace with alice's ethereum address
|
# Replace with alice's ethereum address
|
||||||
ALICE_ADDRESS = os.environ['DEMO_ALICE_ADDRESS']
|
ALICE_ADDRESS = os.environ["DEMO_ALICE_ADDRESS"]
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise RuntimeError('Missing environment variables to run demo.')
|
raise RuntimeError("Missing environment variables to run demo.")
|
||||||
|
|
||||||
print("\n************** Setup **************\n")
|
print("\n************** Setup **************\n")
|
||||||
|
|
||||||
|
@ -76,13 +75,14 @@ connect_web3_provider(eth_provider_uri=L2_PROVIDER)
|
||||||
# WARNING: Never give your mainnet password or mnemonic phrase to anyone.
|
# WARNING: Never give your mainnet password or mnemonic phrase to anyone.
|
||||||
# Do not use mainnet keys, create a dedicated software wallet to use for this demo.
|
# Do not use mainnet keys, create a dedicated software wallet to use for this demo.
|
||||||
wallet = Signer.from_signer_uri(SIGNER_URI)
|
wallet = Signer.from_signer_uri(SIGNER_URI)
|
||||||
password = os.environ.get('DEMO_ALICE_PASSWORD') or getpass(f"Enter password to unlock Alice's wallet ({ALICE_ADDRESS[:8]}): ")
|
password = os.environ.get("DEMO_ALICE_PASSWORD") or getpass(
|
||||||
|
f"Enter password to unlock Alice's wallet ({ALICE_ADDRESS[:8]}): "
|
||||||
|
)
|
||||||
wallet.unlock_account(account=ALICE_ADDRESS, password=password)
|
wallet.unlock_account(account=ALICE_ADDRESS, password=password)
|
||||||
|
|
||||||
# This is Alice's PRE payment method.
|
# This is Alice's PRE payment method.
|
||||||
pre_payment_method = SubscriptionManagerPayment(
|
pre_payment_method = SubscriptionManagerPayment(
|
||||||
network=L2_NETWORK,
|
network=L2_NETWORK, eth_provider=L2_PROVIDER
|
||||||
eth_provider=L2_PROVIDER
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# This is Alice.
|
# This is Alice.
|
||||||
|
@ -121,7 +121,8 @@ price = alice.pre_payment_method.quote(expiration=expiration.epoch, shares=share
|
||||||
|
|
||||||
# Alice grants access to Bob...
|
# Alice grants access to Bob...
|
||||||
policy = alice.grant(
|
policy = alice.grant(
|
||||||
remote_bob, label,
|
remote_bob,
|
||||||
|
label,
|
||||||
threshold=threshold,
|
threshold=threshold,
|
||||||
shares=shares,
|
shares=shares,
|
||||||
value=price,
|
value=price,
|
||||||
|
@ -142,13 +143,12 @@ del alice
|
||||||
|
|
||||||
# Now that Bob has access to the policy, let's show how Enrico the Encryptor
|
# Now that Bob has access to the policy, let's show how Enrico the Encryptor
|
||||||
# can share data with the members of this Policy and then how Bob retrieves it.
|
# can share data with the members of this Policy and then how Bob retrieves it.
|
||||||
with open(BOOK_PATH, 'rb') as file:
|
with open(BOOK_PATH, "rb") as file:
|
||||||
finnegans_wake = file.readlines()
|
finnegans_wake = file.readlines()
|
||||||
|
|
||||||
print("\n************** Encrypt and Retrieve **************\n")
|
print("\n************** Encrypt and Retrieve **************\n")
|
||||||
|
|
||||||
for counter, plaintext in enumerate(finnegans_wake):
|
for counter, plaintext in enumerate(finnegans_wake):
|
||||||
|
|
||||||
# Enrico knows the policy's public key from a side-channel.
|
# Enrico knows the policy's public key from a side-channel.
|
||||||
# In this demo a new enrico is being constructed for each line of text.
|
# In this demo a new enrico is being constructed for each line of text.
|
||||||
# This demonstrates how many individual encryptors may encrypt for a single policy.
|
# This demonstrates how many individual encryptors may encrypt for a single policy.
|
||||||
|
@ -166,9 +166,11 @@ for counter, plaintext in enumerate(finnegans_wake):
|
||||||
###############
|
###############
|
||||||
|
|
||||||
# Now Bob can retrieve the original message by requesting re-encryption from nodes.
|
# Now Bob can retrieve the original message by requesting re-encryption from nodes.
|
||||||
cleartexts = bob.retrieve_and_decrypt([message_kit],
|
cleartexts = bob.retrieve_and_decrypt(
|
||||||
alice_verifying_key=alice_verifying_key,
|
[message_kit],
|
||||||
encrypted_treasure_map=policy.treasure_map)
|
alice_verifying_key=alice_verifying_key,
|
||||||
|
encrypted_treasure_map=policy.treasure_map,
|
||||||
|
)
|
||||||
|
|
||||||
# We show that indeed this is the passage originally encrypted by Enrico.
|
# We show that indeed this is the passage originally encrypted by Enrico.
|
||||||
assert plaintext == cleartexts[0]
|
assert plaintext == cleartexts[0]
|
|
@ -34,29 +34,28 @@ from nucypher.utilities.logging import GlobalLoggerSettings
|
||||||
# Boring setup stuff #
|
# Boring setup stuff #
|
||||||
######################
|
######################
|
||||||
|
|
||||||
LOG_LEVEL = 'info'
|
LOG_LEVEL = "info"
|
||||||
GlobalLoggerSettings.set_log_level(log_level_name=LOG_LEVEL)
|
GlobalLoggerSettings.set_log_level(log_level_name=LOG_LEVEL)
|
||||||
GlobalLoggerSettings.start_console_logging()
|
GlobalLoggerSettings.start_console_logging()
|
||||||
|
|
||||||
TEMP_ALICE_DIR = Path('/', 'tmp', 'heartbeat-demo-alice')
|
TEMP_ALICE_DIR = Path("/", "tmp", "heartbeat-demo-alice")
|
||||||
POLICY_FILENAME = "policy-metadata.json"
|
POLICY_FILENAME = "policy-metadata.json"
|
||||||
shutil.rmtree(TEMP_ALICE_DIR, ignore_errors=True)
|
shutil.rmtree(TEMP_ALICE_DIR, ignore_errors=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
# Replace with ethereum RPC endpoint
|
# Replace with ethereum RPC endpoint
|
||||||
L1_PROVIDER = os.environ['DEMO_L1_PROVIDER_URI']
|
L1_PROVIDER = os.environ["DEMO_L1_PROVIDER_URI"]
|
||||||
L2_PROVIDER = os.environ['DEMO_L2_PROVIDER_URI']
|
L2_PROVIDER = os.environ["DEMO_L2_PROVIDER_URI"]
|
||||||
|
|
||||||
# Replace with wallet filepath.
|
# Replace with wallet filepath.
|
||||||
WALLET_FILEPATH = os.environ['DEMO_L2_WALLET_FILEPATH']
|
WALLET_FILEPATH = os.environ["DEMO_L2_WALLET_FILEPATH"]
|
||||||
SIGNER_URI = f'keystore://{WALLET_FILEPATH}'
|
SIGNER_URI = f"keystore://{WALLET_FILEPATH}"
|
||||||
|
|
||||||
# Replace with alice's ethereum address
|
# Replace with alice's ethereum address
|
||||||
ALICE_ADDRESS = os.environ['DEMO_ALICE_ADDRESS']
|
ALICE_ADDRESS = os.environ["DEMO_ALICE_ADDRESS"]
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise RuntimeError('Missing environment variables to run demo.')
|
raise RuntimeError("Missing environment variables to run demo.")
|
||||||
|
|
||||||
L1_NETWORK = "lynx"
|
L1_NETWORK = "lynx"
|
||||||
L2_NETWORK = "mumbai"
|
L2_NETWORK = "mumbai"
|
||||||
|
@ -74,13 +73,14 @@ connect_web3_provider(eth_provider_uri=L2_PROVIDER) # Connect to the layer 2 pr
|
||||||
# WARNING: Never give your mainnet password or mnemonic phrase to anyone.
|
# WARNING: Never give your mainnet password or mnemonic phrase to anyone.
|
||||||
# Do not use mainnet keys, create a dedicated software wallet to use for this demo.
|
# Do not use mainnet keys, create a dedicated software wallet to use for this demo.
|
||||||
wallet = Signer.from_signer_uri(SIGNER_URI)
|
wallet = Signer.from_signer_uri(SIGNER_URI)
|
||||||
password = os.environ.get('DEMO_ALICE_PASSWORD') or getpass(f"Enter password to unlock Alice's wallet ({ALICE_ADDRESS[:8]}): ")
|
password = os.environ.get("DEMO_ALICE_PASSWORD") or getpass(
|
||||||
|
f"Enter password to unlock Alice's wallet ({ALICE_ADDRESS[:8]}): "
|
||||||
|
)
|
||||||
wallet.unlock_account(account=ALICE_ADDRESS, password=password)
|
wallet.unlock_account(account=ALICE_ADDRESS, password=password)
|
||||||
|
|
||||||
# This is Alice's PRE payment method.
|
# This is Alice's PRE payment method.
|
||||||
pre_payment_method = SubscriptionManagerPayment(
|
pre_payment_method = SubscriptionManagerPayment(
|
||||||
network=L2_NETWORK,
|
network=L2_NETWORK, eth_provider=L2_PROVIDER
|
||||||
eth_provider=L2_PROVIDER
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# This is Alicia.
|
# This is Alicia.
|
||||||
|
@ -101,7 +101,7 @@ alicia.start_learning_loop(now=True)
|
||||||
# At this point, Alicia is fully operational and can create policies.
|
# At this point, Alicia is fully operational and can create policies.
|
||||||
# The Policy Label is a bytestring that categorizes the data that Alicia wants to share.
|
# The Policy Label is a bytestring that categorizes the data that Alicia wants to share.
|
||||||
# Note: we add some random chars to create different policies, only for demonstration purposes
|
# Note: we add some random chars to create different policies, only for demonstration purposes
|
||||||
label = "heart-data-❤️-"+os.urandom(4).hex()
|
label = "heart-data-❤️-" + os.urandom(4).hex()
|
||||||
label = label.encode()
|
label = label.encode()
|
||||||
|
|
||||||
# Alicia can create the public key associated to the policy label,
|
# Alicia can create the public key associated to the policy label,
|
||||||
|
@ -121,9 +121,7 @@ print(
|
||||||
# heart rate measurements from a heart monitor
|
# heart rate measurements from a heart monitor
|
||||||
import heart_monitor # noqa: E402
|
import heart_monitor # noqa: E402
|
||||||
|
|
||||||
heart_monitor.generate_heart_rate_samples(policy_pubkey,
|
heart_monitor.generate_heart_rate_samples(policy_pubkey, samples=50, save_as_file=True)
|
||||||
samples=50,
|
|
||||||
save_as_file=True)
|
|
||||||
|
|
||||||
|
|
||||||
# Alicia now wants to share data associated with this label.
|
# Alicia now wants to share data associated with this label.
|
||||||
|
@ -149,11 +147,13 @@ threshold, shares = 2, 3
|
||||||
# With this information, Alicia creates a policy granting access to Bob.
|
# With this information, Alicia creates a policy granting access to Bob.
|
||||||
# The policy is sent to the TACo Application on the Threshold Network.
|
# The policy is sent to the TACo Application on the Threshold Network.
|
||||||
print("Creating access policy for the Doctor...")
|
print("Creating access policy for the Doctor...")
|
||||||
policy = alicia.grant(bob=doctor_strange,
|
policy = alicia.grant(
|
||||||
label=label,
|
bob=doctor_strange,
|
||||||
threshold=threshold,
|
label=label,
|
||||||
shares=shares,
|
threshold=threshold,
|
||||||
expiration=policy_end_datetime)
|
shares=shares,
|
||||||
|
expiration=policy_end_datetime,
|
||||||
|
)
|
||||||
print("Done!")
|
print("Done!")
|
||||||
|
|
||||||
# For the demo, we need a way to share with Bob some additional info
|
# For the demo, we need a way to share with Bob some additional info
|
||||||
|
@ -162,9 +162,9 @@ policy_info = {
|
||||||
"policy_pubkey": policy.public_key.to_compressed_bytes().hex(),
|
"policy_pubkey": policy.public_key.to_compressed_bytes().hex(),
|
||||||
"alice_sig_pubkey": bytes(alicia.stamp).hex(),
|
"alice_sig_pubkey": bytes(alicia.stamp).hex(),
|
||||||
"label": label.decode("utf-8"),
|
"label": label.decode("utf-8"),
|
||||||
"treasure_map": base64.b64encode(bytes(policy.treasure_map)).decode()
|
"treasure_map": base64.b64encode(bytes(policy.treasure_map)).decode(),
|
||||||
}
|
}
|
||||||
|
|
||||||
filename = POLICY_FILENAME
|
filename = POLICY_FILENAME
|
||||||
with open(filename, 'w') as f:
|
with open(filename, "w") as f:
|
||||||
json.dump(policy_info, f)
|
json.dump(policy_info, f)
|
|
@ -51,7 +51,7 @@ doctor = Bob(
|
||||||
print("Doctor = ", doctor)
|
print("Doctor = ", doctor)
|
||||||
|
|
||||||
# Let's join the policy generated by Alicia. We just need some info about it.
|
# Let's join the policy generated by Alicia. We just need some info about it.
|
||||||
with open("policy-metadata.json", 'r') as f:
|
with open("policy-metadata.json", "r") as f:
|
||||||
policy_data = json.load(f)
|
policy_data = json.load(f)
|
||||||
|
|
||||||
policy_pubkey = PublicKey.from_compressed_bytes(
|
policy_pubkey = PublicKey.from_compressed_bytes(
|
||||||
|
@ -61,14 +61,16 @@ alices_sig_pubkey = PublicKey.from_compressed_bytes(
|
||||||
bytes.fromhex(policy_data["alice_sig_pubkey"])
|
bytes.fromhex(policy_data["alice_sig_pubkey"])
|
||||||
)
|
)
|
||||||
label = policy_data["label"].encode()
|
label = policy_data["label"].encode()
|
||||||
treasure_map = EncryptedTreasureMap.from_bytes(base64.b64decode(policy_data["treasure_map"].encode()))
|
treasure_map = EncryptedTreasureMap.from_bytes(
|
||||||
|
base64.b64decode(policy_data["treasure_map"].encode())
|
||||||
|
)
|
||||||
|
|
||||||
# The Doctor can retrieve encrypted data which he can decrypt with his private key.
|
# The Doctor can retrieve encrypted data which he can decrypt with his private key.
|
||||||
# But first we need some encrypted data!
|
# But first we need some encrypted data!
|
||||||
# Let's read the file produced by the heart monitor and unpack the MessageKits,
|
# Let's read the file produced by the heart monitor and unpack the MessageKits,
|
||||||
# which are the individual ciphertexts.
|
# which are the individual ciphertexts.
|
||||||
data = msgpack.load(open("heart_data.msgpack", "rb"), raw=False)
|
data = msgpack.load(open("heart_data.msgpack", "rb"), raw=False)
|
||||||
message_kits = (MessageKit.from_bytes(k) for k in data['kits'])
|
message_kits = (MessageKit.from_bytes(k) for k in data["kits"])
|
||||||
|
|
||||||
# Now he can ask the TACo nodes on the Threshold Network
|
# Now he can ask the TACo nodes on the Threshold Network
|
||||||
# to get a re-encrypted version of each MessageKit.
|
# to get a re-encrypted version of each MessageKit.
|
||||||
|
@ -77,7 +79,7 @@ for message_kit in message_kits:
|
||||||
retrieved_plaintexts = doctor.retrieve_and_decrypt(
|
retrieved_plaintexts = doctor.retrieve_and_decrypt(
|
||||||
[message_kit],
|
[message_kit],
|
||||||
alice_verifying_key=alices_sig_pubkey,
|
alice_verifying_key=alices_sig_pubkey,
|
||||||
encrypted_treasure_map=treasure_map
|
encrypted_treasure_map=treasure_map,
|
||||||
)
|
)
|
||||||
end = timer()
|
end = timer()
|
||||||
|
|
||||||
|
@ -85,8 +87,8 @@ for message_kit in message_kits:
|
||||||
|
|
||||||
# Now we can get the heart rate and the associated timestamp,
|
# Now we can get the heart rate and the associated timestamp,
|
||||||
# generated by the heart rate monitor.
|
# generated by the heart rate monitor.
|
||||||
heart_rate = plaintext['heart_rate']
|
heart_rate = plaintext["heart_rate"]
|
||||||
timestamp = maya.MayaDT(plaintext['timestamp'])
|
timestamp = maya.MayaDT(plaintext["timestamp"])
|
||||||
|
|
||||||
# This code block simply pretty prints the heart rate info
|
# This code block simply pretty prints the heart rate info
|
||||||
terminal_size = shutil.get_terminal_size().columns
|
terminal_size = shutil.get_terminal_size().columns
|
|
@ -3,8 +3,8 @@ from pathlib import Path
|
||||||
|
|
||||||
from nucypher_core.umbral import PublicKey, SecretKey
|
from nucypher_core.umbral import PublicKey, SecretKey
|
||||||
|
|
||||||
DOCTOR_PUBLIC_JSON = Path('doctor.public.json')
|
DOCTOR_PUBLIC_JSON = Path("doctor.public.json")
|
||||||
DOCTOR_PRIVATE_JSON = Path('doctor.private.json')
|
DOCTOR_PRIVATE_JSON = Path("doctor.private.json")
|
||||||
|
|
||||||
|
|
||||||
def generate_doctor_keys():
|
def generate_doctor_keys():
|
||||||
|
@ -16,7 +16,7 @@ def generate_doctor_keys():
|
||||||
"sig": sig_privkey.to_be_bytes().hex(),
|
"sig": sig_privkey.to_be_bytes().hex(),
|
||||||
}
|
}
|
||||||
|
|
||||||
with open(DOCTOR_PRIVATE_JSON, 'w') as f:
|
with open(DOCTOR_PRIVATE_JSON, "w") as f:
|
||||||
json.dump(doctor_privkeys, f)
|
json.dump(doctor_privkeys, f)
|
||||||
|
|
||||||
enc_pubkey = enc_privkey.public_key()
|
enc_pubkey = enc_privkey.public_key()
|
||||||
|
@ -25,7 +25,7 @@ def generate_doctor_keys():
|
||||||
"enc": enc_pubkey.to_compressed_bytes().hex(),
|
"enc": enc_pubkey.to_compressed_bytes().hex(),
|
||||||
"sig": sig_pubkey.to_compressed_bytes().hex(),
|
"sig": sig_pubkey.to_compressed_bytes().hex(),
|
||||||
}
|
}
|
||||||
with open(DOCTOR_PUBLIC_JSON, 'w') as f:
|
with open(DOCTOR_PUBLIC_JSON, "w") as f:
|
||||||
json.dump(doctor_pubkeys, f)
|
json.dump(doctor_pubkeys, f)
|
||||||
|
|
||||||
|
|
Before Width: | Height: | Size: 677 KiB After Width: | Height: | Size: 677 KiB |
|
@ -5,12 +5,12 @@ import msgpack
|
||||||
|
|
||||||
from nucypher.characters.lawful import Enrico
|
from nucypher.characters.lawful import Enrico
|
||||||
|
|
||||||
HEART_DATA_FILENAME = 'heart_data.msgpack'
|
HEART_DATA_FILENAME = "heart_data.msgpack"
|
||||||
|
|
||||||
|
|
||||||
def generate_heart_rate_samples(policy_pubkey,
|
def generate_heart_rate_samples(
|
||||||
samples: int = 500,
|
policy_pubkey, samples: int = 500, save_as_file: bool = False
|
||||||
save_as_file: bool = False):
|
):
|
||||||
data_source = Enrico(encrypting_key=policy_pubkey)
|
data_source = Enrico(encrypting_key=policy_pubkey)
|
||||||
|
|
||||||
heart_rate = 80
|
heart_rate = 80
|
||||||
|
@ -20,13 +20,12 @@ def generate_heart_rate_samples(policy_pubkey,
|
||||||
for _ in range(samples):
|
for _ in range(samples):
|
||||||
# Simulated heart rate data
|
# Simulated heart rate data
|
||||||
# Normal resting heart rate for adults: between 60 to 100 BPM
|
# Normal resting heart rate for adults: between 60 to 100 BPM
|
||||||
heart_rate = random.randint(max(60, heart_rate-5),
|
heart_rate = random.randint(max(60, heart_rate - 5), min(100, heart_rate + 5))
|
||||||
min(100, heart_rate+5))
|
|
||||||
now += 3
|
now += 3
|
||||||
|
|
||||||
heart_rate_data = {
|
heart_rate_data = {
|
||||||
'heart_rate': heart_rate,
|
"heart_rate": heart_rate,
|
||||||
'timestamp': now,
|
"timestamp": now,
|
||||||
}
|
}
|
||||||
|
|
||||||
plaintext = msgpack.dumps(heart_rate_data, use_bin_type=True)
|
plaintext = msgpack.dumps(heart_rate_data, use_bin_type=True)
|
||||||
|
@ -36,7 +35,7 @@ def generate_heart_rate_samples(policy_pubkey,
|
||||||
kits.append(kit_bytes)
|
kits.append(kit_bytes)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'kits': kits,
|
"kits": kits,
|
||||||
}
|
}
|
||||||
|
|
||||||
if save_as_file:
|
if save_as_file:
|
Loading…
Reference in New Issue