Commit 1120204e authored by Koen van der Veen's avatar Koen van der Veen
Browse files

fix bugs

parent 4ed4a07f
Pipeline #3583 passed with stages
in 3 minutes and 3 seconds
Showing with 89 additions and 188 deletions
+89 -188
......@@ -84,10 +84,8 @@ class GmailImporter(PluginBase):
"""Imports gmail emails over imap."""
def __init__(self, client=None, pluginRun=None, *args, **kwargs):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.pod_client = client
self.pluginRun = pluginRun
self.imap_client = None
self.stop_early_at = self.MAX_IMPORTS
self.authenticator = None
......@@ -191,25 +189,23 @@ class GmailImporter(PluginBase):
for mail, thread_id in self.imap_client.get_mails(batch_ids):
item = self.create_item_from_mail(mail, thread_id=thread_id)
self.pod_client.create_if_external_id_not_exists(item)
self.client.create_if_external_id_not_exists(item)
mails.append(item)
progress = (i + 1) / n_batches * 1.0
return mails
def run(self, client=None):
def run(self):
"""This is the main function of the Gmail importer. It runs the importer given information
provided in the plugin run."""
self.pod_client = client
self.pluginRun.status = RUN_STARTED
self.pod_client.update_item(self.pluginRun)
self.client.update_item(self.pluginRun)
print(self.pod_client.get(self.pluginRun.id).status)
print(self.pod_client.get(self.pluginRun.id).account)
print(self.client.get(self.pluginRun.id).status)
print(self.client.get(self.pluginRun.id).account)
self.authenticator = PasswordAuthenticator(client=self.pod_client, pluginRun=self.pluginRun)
self.authenticator = PasswordAuthenticator(client=self.client, pluginRun=self.pluginRun)
print("To simulate front-end, run:")
print(f"password_simulator --run_id {self.pluginRun.id}")
......@@ -225,23 +221,23 @@ class GmailImporter(PluginBase):
print("Importing accounts...")
all_accounts = get_unique_accounts(all_mails)
for item in all_accounts:
self.pod_client.create_if_external_id_not_exists(item)
self.client.create_if_external_id_not_exists(item)
print("Importing channels...")
all_channels = get_unique_message_channels(all_mails)
for item in all_channels:
self.pod_client.create_if_external_id_not_exists(item)
self.client.create_if_external_id_not_exists(item)
for email_item in all_mails:
for e in email_item.get_all_edges():
self.pod_client.create_edge(e)
self.client.create_edge(e)
self.pluginRun.status = RUN_COMPLETED
self.pod_client.update_item(self.pluginRun)
self.client.update_item(self.pluginRun)
print(f"Finished running {self}")
def add_to_schema(self, pod_client):
pod_client.add_to_schema(EmailMessage(externalId="", subject="", dateSent=0, content=""))
pod_client.add_to_schema(MessageChannel(externalId=""))
pod_client.add_to_schema(Person(externalId="", firstName=""))
pod_client.add_to_schema(Account(externalId=""))
\ No newline at end of file
def add_to_schema(self):
self.client.add_to_schema(EmailMessage(externalId="", subject="", dateSent=0, content=""))
self.client.add_to_schema(MessageChannel(externalId=""))
self.client.add_to_schema(Person(externalId="", firstName=""))
self.client.add_to_schema(Account(externalId=""))
\ No newline at end of file
%% Cell type:code id: tags:
``` python
%load_ext autoreload
%autoreload 2
# default_exp plugin
```
%% Cell type:code id: tags:
``` python
# export
import json
import re
import imaplib
import email
import math
from email import policy
from email.utils import getaddresses
from nbdev.showdoc import show_doc
import time
import pathlib
from pymemri.pod.client import PodClient
from pymemri.data.basic import *
from pymemri.data.schema import EmailMessage, MessageChannel, Person
from pymemri.plugin.schema import PluginRun, Account
from pymemri.plugin.pluginbase import PluginBase
from pymemri.data.itembase import Item
from pymemri.plugin.states import RUN_COMPLETED, RUN_USER_ACTION_NEEDED
from pymemri.plugin.states import RUN_USER_ACTION_COMPLETED, RUN_STARTED, RUN_FAILED
import gmail_importer
from gmail_importer.imap_client import IMAPClient, DEFAULT_GMAIL_HOST, DEFAULT_PORT
from gmail_importer.authenticator import PasswordAuthenticator
```
%% Cell type:code id: tags:
``` python
# export
BASE_PATH = pathlib.Path(gmail_importer.__file__).parent
```
%% Cell type:markdown id: tags:
# Email importer
%% Cell type:markdown id: tags:
This importers fetches your emails and accounts over IMAP, it uses the python built-in imap client and some convenience functions for easier usage, batching and importing to the pod. This importer requires you to login with your email address and an app password. It is tested on gmail, but should work for other IMAP-servers.
%% Cell type:markdown id: tags:
> Note: **The recommended usage for Gmail is to enable two-factor authentication. In this case, make sure you allow [SMTP-connections](https://www.gmass.co/blog/gmail-smtp/) and set an application password (explained in the same link)**
%% Cell type:markdown id: tags:
## ImapClient
The `EmailImporter` communicates with email providers over imap. We created a convenience class around pythons imaplib , called the `ImapClient` that lets you list your mailboxes, retriev your mails and get their content.
%% Cell type:code id: tags:
``` python
show_doc(IMAPClient)
```
%% Output
<h2 id="IMAPClient" class="doc_header"><code>class</code> <code>IMAPClient</code><a href="https://gitlab.memri.io/memri/gmail_importer/tree/prod/gmail_importer/imap_client.py#L21" class="source_link" style="float:right">[source]</a></h2>
> <code>IMAPClient</code>(**`username`**, **`app_pw`**, **`host`**=*`'imap.gmail.com'`*, **`port`**=*`993`*)
%% Cell type:code id: tags:
``` python
show_doc(IMAPClient.list_mailboxes)
```
%% Output
<h4 id="IMAPClient.list_mailboxes" class="doc_header"><code>IMAPClient.list_mailboxes</code><a href="https://gitlab.memri.io/memri/gmail_importer/tree/prod/gmail_importer/imap_client.py#L37" class="source_link" style="float:right">[source]</a></h4>
> <code>IMAPClient.list_mailboxes</code>()
Lists all available mailboxes
%% Cell type:code id: tags:
``` python
show_doc(IMAPClient.get_all_mail_ids)
```
%% Output
<h4 id="IMAPClient.get_all_mail_ids" class="doc_header"><code>IMAPClient.get_all_mail_ids</code><a href="https://gitlab.memri.io/memri/gmail_importer/tree/prod/gmail_importer/imap_client.py#L68" class="source_link" style="float:right">[source]</a></h4>
> <code>IMAPClient.get_all_mail_ids</code>()
retrieves all mail ids from the selected mailbox
%% Cell type:code id: tags:
``` python
show_doc(IMAPClient.get_mail)
```
%% Output
<h4 id="IMAPClient.get_mail" class="doc_header"><code>IMAPClient.get_mail</code><a href="https://gitlab.memri.io/memri/gmail_importer/tree/prod/gmail_importer/imap_client.py#L76" class="source_link" style="float:right">[source]</a></h4>
> <code>IMAPClient.get_mail</code>(**`id`**)
Fetches a mail given a id, returns (raw_mail, thread_id)
%% Cell type:code id: tags:
``` python
# export
# hide
def part_to_str(part):
bytes_ = part.get_payload(decode=True)
charset = part.get_content_charset('iso-8859-1')
chars = bytes_.decode(charset, 'replace')
return chars
def _get_all_parts(part):
payload = part.get_payload()
if isinstance(payload, list):
return [x for p in payload for x in _get_all_parts(p)]
else:
return [part]
def get_unique_accounts(all_mails):
all_accounts = {}
for email_item in all_mails:
for account in email_item.sender + email_item.receiver + email_item.replyTo:
if not account.externalId in all_accounts:
all_accounts[account.externalId] = account
for email_item in all_mails:
for edge in email_item.get_all_edges():
if isinstance(edge.target, Account):
edge.target = all_accounts[edge.target.externalId]
return list(all_accounts.values())
def get_unique_message_channels(all_mails):
all_channels = {}
for email_item in all_mails:
for channel in email_item.messageChannel:
if not channel.externalId in all_channels:
all_channels[channel.externalId] = channel
for email_item in all_mails:
for edge in email_item.get_all_edges():
if isinstance(edge.target, MessageChannel):
edge.target = all_channels[edge.target.externalId]
return list(all_channels.values())
```
%% Cell type:markdown id: tags:
## EmailImporter
%% Cell type:code id: tags:
``` python
# export
class GmailImporter(PluginBase):
MAX_IMPORTS = 100
SLEEP_INTERVAL = 1
MAX_LOGIN_ATTEMPTS = 10
"""Imports gmail emails over imap."""
def __init__(self, client=None, pluginRun=None, *args, **kwargs):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.pod_client = client
self.pluginRun = pluginRun
self.imap_client = None
self.stop_early_at = self.MAX_IMPORTS
self.authenticator = None
self.apply_run_settings()
def apply_run_settings(self):
settings = self.pluginRun.settings
if settings:
settings = json.loads(settings)
else:
settings = dict()
self.stop_early_at = settings.get("max_imports", self.MAX_IMPORTS)
self.simulate_frontend = settings.get(bool("simulate_frontend"), False)
def login(self, username=None, password=None, imap_host=DEFAULT_GMAIL_HOST, port=DEFAULT_PORT):
assert imap_host is not None and port is not None
print(f'Using, HOST: {imap_host}, PORT: {port}')
self.imap_client = IMAPClient(username=username, app_pw=password, host=imap_host,port=993)
print("Imap client logged in.")
@staticmethod
def get_timestamp_from_message(message):
date = message["date"]
parsed_time = email.utils.parsedate(date)
dt = email.utils.parsedate_to_datetime(date)
timestamp = int(dt.timestamp() * 1000)
return timestamp
@staticmethod
def get_accounts(message, field):
addresses = getaddresses(message.get_all(field, []))
return [Account(externalId=address, identifier=name) for name, address in addresses]
@staticmethod
def get_content(message):
"""Extracts content from a python email message"""
maintype = message.get_content_maintype()
if maintype == 'multipart':
parts = _get_all_parts(message)
res = None
html_parts = [part_to_str(part) for part in parts if part.get_content_type() == "text/html"]
if len(html_parts) > 0:
if len(html_parts) > 1:
error_msg = "\n AND \n".join(html_parts)
print(f"WARNING: FOUND MULTIPLE HTML PARTS IN ONE MESSAGE {error_msg}")
return html_parts[0]
else:
return parts[0].get_payload()
elif maintype == 'text':
return message.get_payload()
@staticmethod
def get_attachments(message):
return list(message.iter_attachments())
def create_item_from_mail(self, mail, thread_id=None):
"""Creates a schema-item from an existing mail"""
message = email.message_from_bytes(mail, policy=policy.SMTP)
message_id, subject = message["message-id"], message["subject"]
timestamp = self.get_timestamp_from_message(message)
content = self.get_content(message)
attachments = self.get_attachments(message)
email_item = EmailMessage(
externalId=message_id,
subject=subject,
dateSent=timestamp,
content=content
)
for a in self.get_accounts(message, 'from'):
email_item.add_edge('sender', a)
for a in self.get_accounts(message, 'to'):
email_item.add_edge('receiver', a)
for a in self.get_accounts(message, 'reply-to'):
email_item.add_edge('replyTo', a)
if thread_id != None:
thread_item = MessageChannel(externalId=thread_id)
email_item.add_edge('messageChannel', thread_item)
return email_item
@staticmethod
def batch(iterable, n=1):
l = len(iterable)
for ndx in range(0, l, n):
yield iterable[ndx:min(ndx + n, l)]
def get_mails(self, mail_ids, batch_size=100):
"""Gets mails from a list of mail ids. You can pass an importer run and podclient
to update the progress of the process"""
mails = []
n_batches = math.ceil(len(mail_ids) / batch_size)
for i, batch_ids in enumerate(self.batch(mail_ids, n=batch_size)):
for mail, thread_id in self.imap_client.get_mails(batch_ids):
item = self.create_item_from_mail(mail, thread_id=thread_id)
self.pod_client.create_if_external_id_not_exists(item)
self.client.create_if_external_id_not_exists(item)
mails.append(item)
progress = (i + 1) / n_batches * 1.0
return mails
def run(self, client=None):
def run(self):
"""This is the main function of the Gmail importer. It runs the importer given information
provided in the plugin run."""
self.pod_client = client
self.pluginRun.status = RUN_STARTED
self.pod_client.update_item(self.pluginRun)
self.client.update_item(self.pluginRun)
print(self.pod_client.get(self.pluginRun.id).status)
print(self.pod_client.get(self.pluginRun.id).account)
print(self.client.get(self.pluginRun.id).status)
print(self.client.get(self.pluginRun.id).account)
self.authenticator = PasswordAuthenticator(client=self.pod_client, pluginRun=self.pluginRun)
self.authenticator = PasswordAuthenticator(client=self.client, pluginRun=self.pluginRun)
print("To simulate front-end, run:")
print(f"password_simulator --run_id {self.pluginRun.id}")
self.authenticator.authenticate(self)
print("Importing emails...")
mail_ids = self.imap_client.get_all_mail_ids()
if self.stop_early_at:
mail_ids = mail_ids[:int(self.stop_early_at)]
all_mails = self.get_mails(mail_ids)
print("Importing accounts...")
all_accounts = get_unique_accounts(all_mails)
for item in all_accounts:
self.pod_client.create_if_external_id_not_exists(item)
self.client.create_if_external_id_not_exists(item)
print("Importing channels...")
all_channels = get_unique_message_channels(all_mails)
for item in all_channels:
self.pod_client.create_if_external_id_not_exists(item)
self.client.create_if_external_id_not_exists(item)
for email_item in all_mails:
for e in email_item.get_all_edges():
self.pod_client.create_edge(e)
self.client.create_edge(e)
self.pluginRun.status = RUN_COMPLETED
self.pod_client.update_item(self.pluginRun)
self.client.update_item(self.pluginRun)
print(f"Finished running {self}")
def add_to_schema(self, pod_client):
pod_client.add_to_schema(EmailMessage(externalId="", subject="", dateSent=0, content=""))
pod_client.add_to_schema(MessageChannel(externalId=""))
pod_client.add_to_schema(Person(externalId="", firstName=""))
pod_client.add_to_schema(Account(externalId=""))
def add_to_schema(self):
self.client.add_to_schema(EmailMessage(externalId="", subject="", dateSent=0, content=""))
self.client.add_to_schema(MessageChannel(externalId=""))
self.client.add_to_schema(Person(externalId="", firstName=""))
self.client.add_to_schema(Account(externalId=""))
```
%% Cell type:markdown id: tags:
## Methods
%% Cell type:code id: tags:
``` python
show_doc(GmailImporter.get_content)
```
%% Output
<h4 id="GmailImporter.get_content" class="doc_header"><code>GmailImporter.get_content</code><a href="__main__.py#L48" class="source_link" style="float:right">[source]</a></h4>
<h4 id="GmailImporter.get_content" class="doc_header"><code>GmailImporter.get_content</code><a href="__main__.py#L46" class="source_link" style="float:right">[source]</a></h4>
> <code>GmailImporter.get_content</code>(**`message`**)
Extracts content from a python email message
%% Cell type:code id: tags:
``` python
show_doc(GmailImporter.create_item_from_mail)
```
%% Output
<h4 id="GmailImporter.create_item_from_mail" class="doc_header"><code>GmailImporter.create_item_from_mail</code><a href="__main__.py#L72" class="source_link" style="float:right">[source]</a></h4>
<h4 id="GmailImporter.create_item_from_mail" class="doc_header"><code>GmailImporter.create_item_from_mail</code><a href="__main__.py#L70" class="source_link" style="float:right">[source]</a></h4>
> <code>GmailImporter.create_item_from_mail</code>(**`mail`**, **`thread_id`**=*`None`*)
Creates a schema-item from an existing mail
%% Cell type:code id: tags:
``` python
show_doc(GmailImporter.run)
```
%% Output
<h4 id="GmailImporter.run" class="doc_header"><code>GmailImporter.run</code><a href="__main__.py#L123" class="source_link" style="float:right">[source]</a></h4>
<h4 id="GmailImporter.run" class="doc_header"><code>GmailImporter.run</code><a href="__main__.py#L121" class="source_link" style="float:right">[source]</a></h4>
> <code>GmailImporter.run</code>(**`client`**=*`None`*)
This is the main function of the Gmail importer. It runs the importer given information
provided in the plugin run.
%% Cell type:markdown id: tags:
## Usage
%% Cell type:markdown id: tags:
### Run from CLI
%% Cell type:code id: tags:
``` python
pod_client = PodClient.from_local_keys()
```
%% Output
reading database_key from /home/eelco/.pymemri/pod_keys/keys.json
reading owner_key from /home/eelco/.pymemri/pod_keys/keys.json
reading database_key from /Users/koen/.pymemri/pod_keys/keys.json
reading owner_key from /Users/koen/.pymemri/pod_keys/keys.json
%% Cell type:code id: tags:
``` python
# This cell is meant to be able to test the importer locally
def get_gmail_creds():
return read_file(HOME_DIR / '.memri' / 'credentials_gmail.txt').split("\n")[:2]
```
%% Cell type:code id: tags:
``` python
account = Account(service="gmail_imap")
run = PluginRun("", "gmail_importer.plugin", "GmailImporter")
run.add_edge("account", account)
run.status = RUN_STARTED
print(run.status)
pod_client.create(run)
pod_client.create(account)
pod_client.create_edge(run.get_edges("account")[0])
print(pod_client.owner_key)
print(pod_client.database_key)
```
%% Output
started
1210127632551467658081890196619587842208079869460745665680737018
3663389737548370717117517152016971000529921158054179307068371938
5186996591675494845279124053436703993738237359416492133010217922
5490769912505827787890611208382711960337838194423907691609182006
%% Cell type:code id: tags:
``` python
run = PluginRun("", "gmail_importer.plugin", "GmailImporter")
run.status = RUN_STARTED
pod_client.create(run)
print(run.status)
print(pod_client.get(run.id).status)
```
%% Output
started
None
started
%% Cell type:code id: tags:
``` python
run = PluginRun("", "gmail_importer.plugin", "GmailImporter")
run.status = RUN_STARTED
pod_client.create(run)
print(run.status)
print(pod_client.get(run.id).status)
```
%% Output
started
None
started
%% Cell type:code id: tags:
``` python
print("To simulate front-end, run:")
print(f"password_simulator --run_id {run.id}")
```
%% Output
To simulate front-end, run:
password_simulator --run_id a30a0DAF8Debf33aF5C822a49fEA1aC5
password_simulator --run_id cb8aE85B2E797699a4338Eb37BdaD44B
%% Cell type:code id: tags:
``` python
# skip
pod_client.get(run.id).account[0].secret
```
%% Cell type:code id: tags:
``` python
```
%% Cell type:code id: tags:
``` python
!run_plugin --config_file="../example_config.json"
```
%% Output
reading database_key from /home/eelco/.pymemri/pod_keys/keys.json
reading owner_key from /home/eelco/.pymemri/pod_keys/keys.json
pod_full_address=http://localhost:3030
owner_key=1210127632551467658081890196619587842208079869460745665680737018
None
[Account (#8e9d2b21196d62dfe65c5bdd7ed5ca34)]
To simulate front-end, run:
password_simulator --run_id AeDE7A5C23Ae22F16D5CEFD832DabfAA
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
^C
Traceback (most recent call last):
File "/home/eelco/anaconda3/envs/pymemri/bin/run_plugin", line 33, in <module>
sys.exit(load_entry_point('pymemri', 'console_scripts', 'run_plugin')())
File "/home/eelco/anaconda3/envs/pymemri/lib/python3.8/site-packages/fastscript/core.py", line 76, in _f
func(**args.__dict__)
File "/home/eelco/projects/pymemri/pymemri/plugin/pluginbase.py", line 220, in run_plugin
run_plugin_from_run_id(run_id=plugin_run_id, client=client)
File "/home/eelco/projects/pymemri/pymemri/plugin/pluginbase.py", line 132, in run_plugin_from_run_id
plugin.run(client)
File "/home/eelco/projects/plugins/gmail/gmail_importer/plugin.py", line 217, in run
self.authenticator.authenticate(self)
File "/home/eelco/projects/plugins/gmail/gmail_importer/authenticator.py", line 46, in authenticate
username, password = self.poll_credentials()
File "/home/eelco/projects/plugins/gmail/gmail_importer/authenticator.py", line 79, in poll_credentials
self.pluginRun = self.client.get(self.pluginRun.id)
File "/home/eelco/projects/pymemri/pymemri/pod/client.py", line 269, in get
res = self._get_item_expanded(id)
File "/home/eelco/projects/pymemri/pymemri/pod/client.py", line 300, in _get_item_expanded
item = self.get(id, expanded=False)
File "/home/eelco/projects/pymemri/pymemri/pod/client.py", line 267, in get
res = self._get_item_with_properties(id)
File "/home/eelco/projects/pymemri/pymemri/pod/client.py", line 330, in _get_item_with_properties
result = requests.post(f"{self.base_url}/get_item", json=body)
File "/home/eelco/anaconda3/envs/pymemri/lib/python3.8/site-packages/requests/api.py", line 119, in post
return request('post', url, data=data, json=json, **kwargs)
File "/home/eelco/anaconda3/envs/pymemri/lib/python3.8/site-packages/requests/api.py", line 61, in request
return session.request(method=method, url=url, **kwargs)
File "/home/eelco/anaconda3/envs/pymemri/lib/python3.8/site-packages/requests/sessions.py", line 542, in request
resp = self.send(prep, **send_kwargs)
File "/home/eelco/anaconda3/envs/pymemri/lib/python3.8/site-packages/requests/sessions.py", line 655, in send
r = adapter.send(request, **kwargs)
File "/home/eelco/anaconda3/envs/pymemri/lib/python3.8/site-packages/requests/adapters.py", line 439, in send
resp = conn.urlopen(
File "/home/eelco/anaconda3/envs/pymemri/lib/python3.8/site-packages/urllib3/connectionpool.py", line 699, in urlopen
httplib_response = self._make_request(
File "/home/eelco/anaconda3/envs/pymemri/lib/python3.8/site-packages/urllib3/connectionpool.py", line 445, in _make_request
six.raise_from(e, None)
File "<string>", line 3, in raise_from
File "/home/eelco/anaconda3/envs/pymemri/lib/python3.8/site-packages/urllib3/connectionpool.py", line 440, in _make_request
httplib_response = conn.getresponse()
File "/home/eelco/anaconda3/envs/pymemri/lib/python3.8/http/client.py", line 1347, in getresponse
response.begin()
File "/home/eelco/anaconda3/envs/pymemri/lib/python3.8/http/client.py", line 307, in begin
version, status, reason = self._read_status()
File "/home/eelco/anaconda3/envs/pymemri/lib/python3.8/http/client.py", line 268, in _read_status
line = str(self.fp.readline(_MAXLINE + 1), "iso-8859-1")
File "/home/eelco/anaconda3/envs/pymemri/lib/python3.8/socket.py", line 669, in readinto
return self._sock.recv_into(b)
KeyboardInterrupt
%% Cell type:code id: tags:
``` python
!run_plugin --plugin_run_id $run.id --owner_key $pod_client.owner_key --database_key $pod_client.database_key
```
%% Output
pod_full_address=http://localhost:3030
owner_key=0406847886098273501043678621314868213021589626108871420613276755
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
polling for credentials...
eelcovdw@gmail.com lfpnjkhxzmpmvuvg
Using, HOST: imap.gmail.com, PORT: 993
Imap client logged in.
Importing emails...
Importing accounts...
Importing channels...
Finished running GmailImporter (#None)
%% Cell type:markdown id: tags:
### Run from Docker
%% Cell type:code id: tags:
``` python
# hide
from nbdev.export import *
notebook2script()
```
%% Output
Converted authenticator.ipynb.
Converted gmailimporter.ipynb.
Converted imapclient.ipynb.
Converted index.ipynb.
%% Cell type:code id: tags:
``` python
```
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment