×

Как djinni переезжал с Python2 на Python3

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Статья с набором действий для тех, кто тоже переезжает. 2to3 делает основную рутину, но еще штук 40 кейсов я погуглила за вас :) Может сэкономлю кому-то 40×20мин.

Полтора года назад задепрекейтили Python2, но особо не мешало, поэтому продолжали сидеть на старом до последнего. В последние месяцы уже начали появляться либы у платежных систем с нужными фичами, которые уже без поддержки Python2.

Исходная инфа: проект без тестов, на Python2.7 и django1.11. Еще jinja2, redis, rq, PostgreSQL, elasticsearch. В целом код простой, питон-части около 50 тыс. строк, еще столько же темплейтов.

Обновлять решила до Python3.7 и django2.2. Почему 3.7, а не 3.9 — у какой-то из старых либ был этот потолок, хотя её потом тоже заменила. Почему django2, а не 3 — это основная либа, для снижения рисков переход сделала чуть меньше.

На момент написания статьи уже 2 недели выждали, метрики и мониторинги проверили, 99% багов точно выловили :)

Переезд получился из таких стадий:

  • 1 выходной «копнуть» попробовать, обновлений того, во что уперлась;
  • следующий месяц заменила в спокойном режиме 3 либы;
  • и за еще один выходной произошел переезд.

График нагрузки прода CPU% остался таким же, выигрыша в производительности не случилось.

Тестовый переезд

Либы были описаны в requirements.txt и запиненные версии и dependencies в constraints.txt (просто перепаст pip freeze)

Начала с того, что зачистила оба — и requirements.txt, и constraints.txt. Потом взяла контейнер с 3.7 питоном и сразу дописывала то, что точно используется. В конце потом примерно треть либ не вернулась — старые неиспользуемые.

Несовместимости сразу вылезли только на 3 либах, и в течение следующего месяца их попереводила на аналоги, в спокойном режиме, где-то между основными задачами.

  • django_elasticsearch_dsl — избавилась от elasticsearch в пользу Postgres Fulltext Search, чтобы работать в рамках одной базы. Объёмы поиска небольшие, поэтому в скорости особо не потеряли. Тут еще были небольшие приключения с конвертацией boolean search из формата elasticsearch в postgres fulltext search.
  • mandrill перевела на свежую mailchimp-transactional (он только для python3, поэтому в код вмержила уже в момент переезда)
  • oauth2client перешла на свежую google-auth

Все остальное вроде подхватилось и работало, после небольших фиксов.

2to3

После замены либ, на выходных уже сделала полноценный переезд.

Первым действием был 2to3, делала 3 отдельных комита:

  • отдельно перевод print-ов (на со скобками)
  • отдельно unicode (без u’’ перед строкой)
  • и отдельно все остальное, чтобы глазами проверить:
2to3-2.7 -w -f print *.py
2to3-2.7 -w -f unicode *.py
2to3-2.7 -w -x print -x unicode *.py

rm *.bak

Еще изменения в Python

  • В местах, связанных с чтением или записью файлов — удалила .decode('utf8')
  • Аналогично при открытии файлов:
with open(fname, 'r') as fd:

изменилось на

with open(fname, 'rb') as fd:
  • Но в местах работы с hashlib наоборот некоторым параметрам добавила .decode('utf8') чтобы привести к bytes, либо .encode('utf8'), чтобы привести к строке результат хеширования, он там чётко ошибки пишет кого и где не хватает
  • в местах использования urllib поубирала .encode('utf8'), urllib теперь сам разбирается что и когда ему нужно
  • При сравнении с integer, None перестал превращаться в 0, в местах проверок:
if self.salary_min >= self.salary75:

изменилось на

if self.salary_min and self.salary75 and self.salary_min >= self.salary75:
  • аналогичное изменение в jinja2-темплейтах, тоже добавила проверки на не-None перед тем как сравнивать int
  • __unicode__ позаменяла на __str__
  • один из ключей к внешним сервисам теперь тоже потребовал быть в bytes:
MY_SOME_KEY = 'aksdfj83jlaksdjf'

изменилось на

MY_SOME_KEY = b'aksdfj83jlaksdjf'
  • там, где нужен был англ. алфавит:
string.letters
string.uppercase

изменилось на

string.ascii_letters
string.ascii_uppercase
  • в одной из функций получилось, что на вход могли приходить и bytes, и str, работать надо было дальше с str, добавила конвертацию просто по ситуации:
if type(v) == bytes:
    v = v.decode('utf8')
  • в некоторых местах для дебага у меня была такая конструкция, в python3 это уже не актуально, её просто поудаляла:
import sys
reload(sys)
sys.setdefaultencoding('utf8')
  • для передачи в темплейт выборки из джанги уже не прокатывал просто q:
recruiters = q[:25].all()

изменилось на

recruiters = list(q[:25].all())

Изменения в django для переезда 1.11 → 2.2.20

  • в settings.py
MESSAGE_LEVEL = 'ERROR'

поменялся на

MESSAGE_LEVEL = logging.ERROR
  • еще в settings.py в TEMPLATES нужно было добавить для django admin:
TEMPLATES = [
	...
         "OPTIONS": {
             "context_processors": [
                 "django.contrib.messages.context_processors.messages",
									...
             ],
         }
...
  • запуск всех скриптов переписала запускаться как python3 manage.py ...
  • is_authenticated:
user.is_authenticated()

поменялся на

user.is_authenticated
  • у класса модели админки, если перебивались fields, то теперь это нужно было делать функцией:
class CandidateAdmin(admin.ModelAdmin):
...
    fields = ['email', ...

изменилось на

class CandidateAdmin(admin.ModelAdmin):
    @property
    def fields(self):
        fields = ...
  • у моделей on_delete стал обязательным параметром, там где его не было, добавила тот, который раньше был дефолтным:
company = models.ForeignKey(Company, on_delete=models.CASCADE, ...
  • у формочек первый параметр с выбором теперь должен стать именованным:
country = forms.ChoiceField(Recruiter.COUNTRIES, required=True)

изменилось на

country = forms.ChoiceField(choices=Recruiter.COUNTRIES, required=True)
  • в django admin, там где генерились спец.поля с html-кодом, изменился формат:
class CandidateAdmin(admin.ModelAdmin):
    ...
    def emaillead_list(self, obj):
        res = '<a href="/my/link">=subs=</a>'.format(obj.email)
				return res
     emaillead_list.allow_tags = True

изменилось на

class CandidateAdmin(admin.ModelAdmin):
    ...
    def emaillead_list(self, obj):
        res = '<a href="/my/link">=subs=</a>'.format(obj.email)
				return mark_safe(res)
  • для авторизации (у нас сессии на куках), новые параметры в settings.py:
SESSION_COOKIE_SAMESITE = None
SESSION_COOKIE_SECURE = True

Изменения из-за обновления либ

  • свежий redis по умолчанию стал работать с bytes, но можно вернуть на str:
redis_main = redis.Redis(settings.REDIS_HOST)

изменилось на

redis_main = redis.Redis(settings.REDIS_HOST, decode_responses=True)
  • свежий redis перестал конвертить boolean в string, поэтому в местах хранения boolean:
redis_main.set(redis_key, is_hot)

изменилось на

redis_main.set(redis_key, int(is_hot))
  • uwsgi тоже изменилась либа, в Dockerfile:
RUN apt-get install -y ... uwsgi-plugin-python python3-pip ...

изменилось на

RUN apt-get install -y ... uwsgi-plugin-python3 python3-pip ...
  • и при запуске uwsgi:
uwsgi ... --plugin=python3 ... --callable application ...

изменилось на

uwsgi ... --plugin=python3 ... --module=djinn.wsgi:application ...
  • при отправке attachments через mailchimp-transactional, при формировании json:
x.append(dict(
   type='image/png',
   name='graph.png',
   content=fdata.encode('base64')
))

заменился на

x.append(dict(
   type='image/png',
   name='graph.png',
   content=base64.b64encode(fdata).decode('utf8')
))
  • для отправки писем через mailchimp-transactional (был mandrill) уже не стало нужно кодировать subj:
subject = (_('Your attachment was not delivered')).encode('utf8')

изменилось на

subject = (_('Your attachment was not delivered'))
  • у свежего rq исчез класс FailedQueue, теперь fail очередь достаётся так:
fq = Queue('failed', connection=Redis(settings.REDIS_HOST, db=settings.REDIS_RQ_NUM))
  • у свежего rq забирать задачу теперь так:
for q_id in myqueue.get_job_ids()[-10:]:
   j = fq.fetch_job(q_id)

изменилось на

for q_id in myqueue.get_job_ids()[-10:]:
   j = Job.fetch(q_id, connection=RQ_CONN)
  • результат выполнения subprocess теперь приходит в bytes, добавила .decode('utf8') для работы с ним как со строкой
  • еще в одном месте при работе с файлами и xlsxwriter.workbook использовалось StringIO, заменила на BytesIO:
output = StringIO.StringIO()
...
row[0] = something.encode('utf-8')

изменилось на

output = BytesIO()
...
row[0] = something
  • в failed очереди rq остались еще пару десятков не сильно важных задач (сохранение логов-поисковых запросов), к которым нет доступа в новом rq, удаляла их так:
from djinn import settings
from redis import Redis
from rq.queue import Queue
qf = Queue('failed', connection=Redis(settings.REDIS_HOST, db=settings.REDIS_RQ_NUM))
len(qf)

qf.empty()

Чеклист проверки на проде, у нас же нет тестов ;)

  • recruiter
  • candidate
  • anonymous
  • pdf invoices
  • email (forgot password)
  • /pulse maillist
  • /analytics (cand, recr, anonymous)
  • /salaries (cand, recr, anonymous)
  • test rq (searchlog)
  • djinni_jobs_bot (привязка, отвязка, создание широкой подписки с переспросом, удаление подписки)
  • djinni_search_bot (привязка, отвязка, создание широкой подписки с переспросом, удаление подписки)
  • подписки на вакансии, которые приходят в бота
  • !!! соц.авторизация
  • test slackdeploy
  • !!! подписки на кандидатов, которые приходят в бота
  • расссылки вторник утром
  • /admin/health
  • export xls ious and hirelists (custom_billing)
  • import jobs
  • оплаты braintree
  • оплаты ukr
  • оплаты wayforpay
  • rq scripts fail очередь протестить

В целом переезд прошел гладко — основной downtime был пару минут в выходной и несколько багов точечно в разных кейсах в последующие дни. Выше перечислены все фиксы, которые понадобились.

👍ПодобаєтьсяСподобалось20
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Скоро еще форум DOU нужно будет переносить на golang, чтобы он выдержал нагрузку, генерируемую Олексієм Пеніє...

На асемблер)

Исходная инфа: проект без тестов,

дальше можна не читати

Я живу в идеальном мире, где не существует багов, секюрити холз, а все проекты имеют 100% покрытие. Нету войн, голода и болезней. and no religion too...

www.youtube.com/watch?v=464Ux-B_5uY

Тести хоча б на фічі треба робити

Або у вас просто не піднімуться руки прибирати зайвий код чи рефакторити

Краще написати листа Максу з фразою "Що це все таке?"© Митець

Що це все таке?

— Це Восток, о діамант моєї душі

для передачи в темплейт выборки из джанги уже не прокатывал просто q:
recruiters = q[:25].all()

изменилось на
recruiters = list(q[:25].all())

Чому в мене відчуття, що це костиль?

если q это QuerySet — то все должно передаваться. all() тут лишне как и list

спробувала в цьому місці recruiters = q[:25]
не спрацювало, помилка:
Population must be a sequence or set. For dicts, use list(d).

ну у нас jinja2, не django templates

так сложилось, и, вроде, не мешает :)

так, all() можна викинути, recruiters = list(q[:25]) достатньо

так, це django queryset, щось, що було так давно
без list() не доходило до темплейту, з list() пофіксилося

У третьому пітоні «utf-8» у encode/decode можна не вказувати, бо то є default value

В принципі сподіватися на default value констант — погана ідея. Наприклад UTF8 з BOM чи без нього?

А підкажіть, як відсутність, чи наявність BOM впливатиме на кодування з/на utf-8?

До чого тут Майкрософт, не зрозумів натяк. Чи то спершу ви мали на увазі кодування «utf-8-sig»?

До того, що Microsoft при збереженні текстових файлів вважав (а може й досі вважає) необхідним ставити BOM. В той час як уся Linux інфраструктура має стандарт де-факто що цей символ не має бути присутнім в UTF-8. Зокрема, через сумісність останньої з англомовним 1-байтовим кодуванням, де набір символів співпадає.

Звісно ж ця сумісність досить умовна, проте факт є фактом, Unicode 16 хоча б формально потребує BOM символа, що визначає порядок байт. В той час коли для UTF-8 він не потрібен, від слова н---й. Але спробуйте довести це Microsoft.

Проблеми ставалися коли конфіг-файли редагувалися текстовими редакторами, зокрема через Notepad. Звісно ж рішенням було тупо не користуватися лайном. Але ж знов, спробуйте довести це Microsoft, що в засоби екстреного доступу, зокрема Install Environment та Recovery Environment, не западлом буде покласти повноцінний текстовий редактор. А не Notepad. Про HEX редактор я вже й не кажу, невже це щось таке неможливе? Так само як і творцям дистрибутивів Linux неможливо довести що редактор vi — то древнє зло, і його треба спалити на вогнищі, замість нього створивши повноцінний редактор, чи взявши вже створений.

PS. Окремого щастя додає факт, що той символ не має візуальної форми. Тобто його так просто не побачиш в редакторі, коли не знаєш, що шукати.

PPS. Це не тема для обговорення. Просто факт, що не є добрим покладатися на дефолти. Вказання кодування дуже спрощує читабельність коду. І коли стаються якісь проблеми, програмісту буде видно по коду, де саме їх бути не може, а де можуть з′явитися.

От тепер мені зрозуміло, що ви хотіли сказати. То так би й сказали, що краще вказувати «utf-8-sig» замість «utf-8». Тобто ваш оригінальний коментар був не про проблему сподівання на default value, а про проблему гетерогенного походження даних. До чого було ліпити фразу про «людину з Майкрософту»? Якщо передбачається гетерогенне походження даних, то тут питання треба розширяти й згадувати про символи нового рядка, тощо.

Я хотів сказати, що кодування і багато чого іншого є хорошим тоном вказувати в коді чи в конфігах. Навіть якщо то дефолтне значення. Це покращує читабельність коду і не створить проблем якщо в нових версіях дефолт зміниться.

Про UTF8 лише референс. Тут це не варто обговорювати. Просто факт, що сюрпризи мають не нульовий шанс з′явитися. Особливо на інтеграціях зі стороннім софтом. Хоча в даному випадку шанс мізерний, проблема вичерпала себе кільканадцять років тому статистично. Хоча й не нуль.

yum erase -y yum

Vi[m] настільки древній, що він існує поза категоріями добра і зла.

Нам, смертним, просто треба читати документацію, стековерфлов, і вчити заклинання.

вчити заклинання.

сирёзно? я годами и уже похоже десятками лет пользуюсь инструкцией кажется максимум на 2 страницы хотя может 3 или даже 4 составленной каким-то университетом возможно беркли ))

и кстати похоже все эти штуки входят в базовый набор не советского университет любого курса CS потому что все встреченные мной индусы как пример весьма годно во всей этой магии шпарят пусть не на уровне магии «как достичь результата» но на уровне «заклинаний» так 146%

...Ткнуть, і там два раза провєрнуть
Своєю миш’ю...

пользуюсь инструкцией

Покажіть, будь ласка

www.cs.colostate.edu/helpdocs/vi.html

таки это был Colorado State University вечно я их путаю ((

The UNIX vi editor is a full screen editor and has two modes of operation

www.cs.colostate.edu/helpdocs/vi.html

vim имеет два режима: всё портить и бибикать

bash.im/quote/401
;)

Тобто є або абсолютним добром (нєт), або абсолютним злом. Як відомо, покинути добро значно простіше, ніж зло :)

о, прикольно, дякую :)

А, я вспомнила, почему Python3.7 — просто контейнер с Debian10 взяла, там из коробки у apt-get 3.7, как надёжный и проверенный.

График нагрузки прода CPU% остался таким же, выигрыша в производительности не случилось

В Python з самого початку все настільки якісно зроблено, шо лишилось лише синтаксичний цукор додавати? В ruby і php, наприклад, всі нові версії швидші за попередні — коли мінімально, а коли і досить відчутно.

Ні, просто Python доволі важко прискорити, бо він весь із себе динамічний.
Частково це виходить — PyPy або щось типу Pyston — але в загальному Python до сих пір є повільним (і має GIL) і це або не критично, або з цим миряться, або вирішують 3 сторонніми лібами, або йдуть з нього куди-інде.

Ну, так в Ruby і PHP теж динамічнічна типізація, але це не заважає їм з кожним релізом ставати швидшими. Чи тут шось інше малось на увазі?

Ну, так в Ruby і PHP теж динамічнічна типізація, але це не заважає їм з кожним релізом ставати швидшими

Про Ruby не знаю, але PHP у порівнянні з Python — як зліплене з дошок на клею.
Власне, проблема не у самій типізації, а у тому, як ця типізація впливає на роботу з пам’яттю — основний приріст швидкодії PHP був якраз коли вони оптимізували роботу з пам’яттю і зі стековими змінними.

Ну вот второй бекенд уже пришел, для ключевых будем писать :)
Но я ж тестировала локально и на стейджинге, совсем самые ключевые — сразу руками протыкиваю и видно — работает или нет.

Тогда получается Макс в рассылке шутил, что вы начнете тесты писать?

Технический долг понемногу возвращаем, через несколько месяцев может и тесты начнем писать. Шучу.

ну мы вряд ли будем покрывать прям все, но критические места, для своего спокойствия, думаю, что сделаем :))

Это не совсем так. При ограниченных ресурсах мозга усложнение в 2 раза может привести к увеличению затрат времени в 20 раз и более, а также наплодить кучу багов, вызванных прерываниями исполнения (для человеческой памяти это бутылочное горлышко).

Так что если что-то можно сделать одним куском, и этот кусок в законченном виде легко понять — так и нужно делать. Брошенный кусок с сотнями недоделок и тысячей проблем с тестами тебе может вообще похоронить проект. А тут — за выходные сделали и взлетело.

Тестами нужно покрывать либо сразу, когда сначала поднимают бекенд. Либо после MVP стадии, когда тесты пишут исключительно для проверки отдельных узлов и внешних сервисов. Попытка вписаться с тестами где-то посредине — это катастрофа для будущих изменений.

Объясняю, почему так: после первой сколь-либо живой версии идёт жёсткий рефакторинг, и код сокращается раза в 2, притом половина его будет так или иначе переписана. Покрытие же тестами говнокода оставит в живых именно говнокод.

Это как с детьми: ты не учишь их зарабатывать деньги, юридическим законам и неписанным правилам. Ты кормишь их молочком, меняешь памперсы, и читаешь сказки про принцев и принцесс. В противном случае ты получишь вместо MVP маленького человечка с большими (и дорогими) психическими проблемами. Если конечно доживёт.

В том-то и дело, что тесты на ключевые места ты не напишешь. Потому что ошибка № 0 — это ошибка именования. В коде даже рефакторинг не проведёшь, если ошибся с именем, пока он не будет хоть как-то собираться. Можно конечно наделать затычек — но угадай, пройдут ли эти затычки тест. И как скоро ты ошибёшься, оставит тестам затычку вместо реального кода.

Или ты веришь, что между написанием реального кода и удалением затычки проходит 0 милисекунд? Уверяю, это не так. И что угодно может тебя прервать в процессе написания, и код останется недописанным, а затычка функциональной.

Мораль: не пиши тесты тому, что можно закончить за 1-2 дня, и ты уверен что закончишь. Начнёшь писать тесты — и не закончишь никогда, ошибки будут оставаться по обратной экспоненте. Что равносильно отсутствию тестов. А если нет разницы — зачем убивать проект бюрократией?

Проблема ж не в коде, а в твоей памяти. Там код должен быть УЖЕ дописанным. Коль скоро ты пишешь TDD, ты вызываешь даже не стирание этой памяти, а деградацию, откат. Грубо говоря, блокировку пути. И если знаешь как работает ассоциативная память, то догадываешься, почему при попытке собрать код снова ты получаешь не новую сборку с нуля, а осколки предыдущей с рассыпавшимися связями, ВКЛЮЧАЯ тот осколок, который осуществлял деградацию.

Если в двух словах, то каждая следующая попытка будет стоить тебе в разы дороже предыдущей. Такова твоя память, и ничего нового ты ей не придумаешь. Потому крайне глупо доверять методологиям от бюрократов, которые сами код не пишут. 99% кода пишется для человека, и менее 1% собственно для машины. Так что даже со 100% покрытием кода тестами ты выполнишь менее 1% тестирования.

Первым делом нужно проверить ЧИТАЕМОСТЬ кода, и уже только тогда строить ПЛАН тестирования, и уже по нему писать тесты, со скоростью хяук, хяук, и в продакшен!!

Первым делом нужно проверить ЧИТАЕМОСТЬ кода,

именно.
как я говорю — в ясно написанном каде ашипки вядно биза тестав.

и, давно практикую, если конечно время позволяет, и прочие it depends ...

прежде чем влезать фиксать баг, смотрю на код, и если он не такой как вокруг, явно пованивает, то:
«пусть сильнее грянет буря» — ломать нафик, перекраивать, гитом если что — откачу
и это быстрее позволит понять причину ошибки, и пофиксать ее и еще парочку подобных которые просто не проявились, чем пошагово трассировать, и т.д.

хрень надо вырезать всю, а не точечно фиксать, добавив — еще флажок, еще классик, ... — точечно
или, хотя б разукрасить код тудушками — вот хрень, вот еще, вот здесь надо будет так, здесь сяк, а пока
костылище. зато тесты побегут, и баг закроют.

А тебе жалко, да? Вот из-за таких жадных людей мир уже не спасти :)

Я не злопамятный, я просто злой, и память хорошая

Проблема ж не в коде, а в твоей памяти. Там код должен быть УЖЕ дописанным.

В моей практике код, с которым это работает, закончился где-то в середине 2000х вместе с двухстраничными скриптами автоматизации.
Всё после этого требует разбивания на логически осмысленные части и независимой проверки этих частей.

Коль скоро ты пишешь TDD, ты вызываешь даже не стирание этой памяти, а деградацию, откат. Грубо говоря, блокировку пути.

Не обязательно TDD. Но test-first часто хорош для того, чтобы вообще создать необходимую структурность коду (какие вещи вызываются, где, как). Часто нахожу, что если не идёт через test-after, то идёт через test-first. Но примерно так же чаще и наоборот.

В общем, ты вводишь общие принципы там, где их быть не может.

Первым делом нужно проверить ЧИТАЕМОСТЬ кода, и уже только тогда строить ПЛАН тестирования

Нужна не читаемость сама по себе, а понимаемость основных целей. С этого момента, да, нужен план тестирования (пусть даже в уме и неявный).

До конкретных деталей можно будет добраться и позже.

Это и есть читаемость. TDD хорош там, где под него не придётся писать затычки, про которые можно потом забыть. Грубо говоря, бэкенд при цели обслуживании высокоразвитого фронтенда лучше писать TDD, и наоборот, такому фронтенду можно написать TDD на бекенде.

Но если один из узлов тупой, ненадёжный, неподвластный и так далее — тут уже часто приходится от TDD отказаться, иначе под него придётся писать костыли, и ничем кроме перерасхода времени это не закончится, и что ещё важнее — костыли должны будут остаться в живом коде.

Лучший вариант, когда TDD себя оправдывает, это когда он целиком остаётся в живом коде. Но тогда это лишь частично TDD, а под ним будут скрываться и другие тесты, запускаемые локально. Или даже возможна многоуровневая система, если пишется что-то с очень длинным жизненным циклом и в слабо предсказуемых условиях работы.

В общем случае оптимизируют время-деньги. Всё остальное вторично. Кроме читаемости кода. Потому что читаемый код — это и есть продукт, и если он оказывается необслуживаемым, то это плохой продукт.

Что помешало сразу перевести синтаксис на готовность к 3.9, чтобы когда нужная либа выйдет (или найдётся ей замена), сразу встать на нужный синтаксис? В конце концов, проверить работоспособность основных частей можно же и без либы. Или там есть такие вещи, что сильно разнятся в версиях и совместимо не напишешь?

в первый тестовый заход уперлась однозначно по версии в 3.7, а потом торопилась, чтобы за выходной успеть, и чтобы был запас времени на неожиданности в еще один выходной, пока юзеров мало :)

Ні разу не рубаю в зміях — пітонах, але обожнюю технічні статті на доу. Ми недавно переходили з 8 джави на Coretto, ще в пам’яті.

З 8-мої джави на 8-ме Корито?

Підписатись на коментарі