Existem linguagens de programação com tipagem forte, fraca, estática e dinâmica. Quem não estuda o assunto com alguma profundidade costuma achar que tipagem forte é sinônimo de estática, e tipagem fraca é sinônimo de dinâmica. Então vamos clarear as definições.
Tipagem estática: uma variável possui tipo predefinido (ainda que implicitamente) e fixo ao longo da sua vida. Exemplos: C, C++, Java, Rust, linguagens compiladas em geral.
Tipagem dinâmica: uma variável possui o tipo do objeto que está armazenando no momento, e pode armazenar objetos de tipos diferentes ao longo da sua vida. Exemplos: Python, PHP, Javascript, linguagens interpretadas em geral.
Tipagem forte: por padrão, um tipo não sofre conversão automática. Por exemplo, numa linguagem forte, a seguinte operação
"2" + 1
não é aceita pois a operação "soma" não é definida para string+inteiro. Ainda é possível converter um tipo em outro, mas essa conversão tem de ser explícita.
C, C++ e Python adotam tipagem forte, com algumas poucas exceções visando a praticidade. Muitos tipos são automaticamente promovidos para booleanos, C/C++ faz promoção automática entre tipos numéricos, etc.
Linguagens modernas (Go, Rust, Swift, Kotlin) adotam tipagem "super forte", proibindo conversão automática até mesmo entre números de diferentes formatos e tamanhos, pois observaram (corretamente) que isso é fonte abundante de bugs.
Tipagem fraca: por padrão, os tipos sofrem conversão automática quando isso é considerado razoável. Por exemplo,
"2" + 1
resulta no número 3 em PHP, e na string "21" em Javascript (veja que o critério é bem diferente em uma e outra). A propósito,
"2" - 1
resulta no número 1 em ambas as linguagens, pois a operação de subtração não faz sentido para strings, então é mais "sensato" converter a string para número, o que viabiliza a operação.
Em geral a tipagem fraca visa agradar usuários com pouca experiência em programação. Javascript, PHP e linguagens 4GL em geral foram inicialmente concebidos para amadores ou não-informatas. Só que a linguagem-brinquedo de ontem vira a ferramenta mainstream de hoje, e a bagagem fica, gerando bugs infames.
Mas tudo isso foi dito apenas para colocar sal numa velha ferida do Python: a implementação canhestra do suporte a Unicode no Python 2, que acredito eu foi o principal motivador da criação do Python 3. Aí a migração do Python 2 para 3 foi extremamente lenta e traumática, sendo frequentemente citada como exemplo de como não fazer a coisa. Mas isso já é outra história.
Até o Python 2.1, havia apenas o tipo str (string), onde caractere e byte eram sinônimos, como o tipo char* do C. Uma string pode seguir um encoding como UTF-8, o que lhe permite representar indiretamente uma string Unicode, mas o tipo str não toma conhecimento disso.
O Python 2.2 introduziu o tipo unicode, onde cada caractere tem 4 bytes (32 bits). Não há encoding: um caractere "físico" sempre corresponde a exatamente um caractere "lógico". O mecanismo de conversão explícita entre os tipos é baseado nos métodos unicode.encode() e str.decode().
Porém, contrariando o espírito de tipagem forte do Python, os tipos unicode e str sofrem conversão automática entre si. Por exemplo, a soma^Wconcatenação
u'abc' + b'def'
funciona no Python 2 e sempre resulta numa string Unicode. O objetivo era louvável: tentar diminuir o impacto no código-fonte existente. Mas na prática não funcionou nada bem. Primeiro, porque uma string de bytes e uma string Unicode são objetos muito diferentes. A conversão deveria sempre ser explícita.
Por exemplo, ao ler um arquivo, obtemos uma string de bytes. É perigoso interpretar automaticamente essa string como Unicode. Qual era o encoding desse arquivo? Quem garante que o arquivo estava codificado corretamente, ou não estava corrompido? Em que ponto o programa vai quebrar se a codificação estiver errada?
Pelo fato do automatismo funcionar em ambas as direções, aconteciam situações infames, como a "dupla conversão", onde o código por engano invocava encode() sobre um objeto str, ou decode() sobre unicode. O pior é que isto não causava problemas se a string (comum ou Unicode) tivesse apenas caracteres ASCII. Só quebrava quando entrava um caractere acentuado na jogada.
Esse é um exemplo de como a tipagem fraca pode quebrar uma linguagem. Mudar isso de novo no Python 2 seria difícil, pois quebraria novamente todos os programas que depois de muito suor e lágrimas conseguiram acertar a mão com o Unicode. Então decidiu-se quebrar a compatibilidade, lançando o Python 3.
No Python 3, temos os tipos bytes e str. O primeiro faz o papel do antigo str, o segundo fazendo o papel do antigo unicode. Esses tipos não podem ser combinados diretamente, a conversão de um para outro é sempre explícita, como de resto é o esperado numa linguagem de tipagem forte.
Unicode ainda é fonte de problemas ao programar em Python, mas isso é mais devido à complexidade inerente do Unicode, que não é um assunto muito simples em nenhuma linguagem.