diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 165acdc..fbd9a8e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,7 @@ on: push: branches: [ master ] pull_request: - branches: [ master ] + # branches: [ master ] release: types: [published] @@ -22,7 +22,7 @@ jobs: - name: Build the wheel run: | - python -m pip install build + python -m pip install --group dev python -m build - name: Upload wheel @@ -33,13 +33,10 @@ jobs: - name: Run the tests run: | - python -m pip install numpy pyparsing pytest pytest-cov pytest -v - name: Build the docs run: | - python -m pip install matplotlib sphinx - python -m pip install dist/periodictable*.whl make -j 4 -C doc/sphinx SPHINXOPTS="-W --keep-going" html # Test the wheel on different platforms @@ -75,17 +72,22 @@ jobs: run: python -m pip install dist/periodictable*.whl shell: bash - - name: Install Python dependencies + # uncertainties and matplotlib are optional packages, but they are needed for mypy tests + - name: Install test dependencies run: | - python -m pip install pytest pytest-cov + python -m pip install --group test + # Use --pyargs --import-mode=append to target the installed package. # Change into the test directory to test the wheel so that the - # source directory is not on the path. Full tests with coverage are - # run before building the wheel. + # source directory is not on the path. We are running coverage tests + # because it shows that it is indeed the installed package that is tested. + # The mypy plugin fails when testing the installed package. Since there is no + # way to turn the option off, so we instead erase the addopts configuration + # and put them explicitly on the pytest command line below without --mypy. - name: Test wheel with pytest run: | cd test - pytest -v --pyargs --import-mode=append periodictable . ../doc/sphinx/guide + pytest -v --pyargs --import-mode=append periodictable --override-ini addopts= --doctest-modules --doctest-glob=*.rst --cov=periodictable . ../doc/sphinx/guide # Upload wheel to PyPI only when a tag is pushed, and its name begins with 'v' upload-to-pypi: diff --git a/MANIFEST.in b/MANIFEST.in index e53b68d..12574b4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,6 +3,7 @@ include README.rst graft periodictable/xsf include periodictable/f0_WaasKirf.dat include periodictable/activation.dat +include periodictable/py.typed prune doc prune doc/sphinx/_build prune doc/sphinx/build diff --git a/periodictable/__init__.py b/periodictable/__init__.py index 1219ef8..4fa4a9f 100644 --- a/periodictable/__init__.py +++ b/periodictable/__init__.py @@ -196,6 +196,9 @@ def __dir__(): livermorium = Lv = elements.symbol("Lv") tennessine = Ts = elements.symbol("Ts") oganesson = Og = elements.symbol("Og") +deuterium = D = elements.symbol("D") +tritium = T = elements.symbol("T") +neutron = n = elements.symbol("n") # Add element name and symbol (e.g. nickel and Ni) to the public attributes. __all__ += core.define_elements(elements, globals()) diff --git a/periodictable/core.py b/periodictable/core.py index e053b4b..a816d01 100644 --- a/periodictable/core.py +++ b/periodictable/core.py @@ -651,7 +651,7 @@ def __init__(self, element: Union["Element", "Isotope"], charge: int): def __getattr__(self, attr: str) -> Any: return getattr(self.element, attr) @property - def mass(self) -> float: + def mass(self) -> float: # type: ignore[override] return getattr(self.element, 'mass') - constants.electron_mass*self.charge def __str__(self) -> str: sign = '+' if self.charge > 0 else '-' @@ -661,12 +661,12 @@ def __str__(self) -> str: def __repr__(self) -> str: return repr(self.element)+'.ion[%d]'%self.charge def __reduce__(self): - try: + if isinstance(self.element, Isotope): return _make_isotope_ion, (self.element.table, self.element.number, self.element.isotope, self.charge) - except Exception: + else: return _make_ion, (self.element.table, self.element.number, self.charge) diff --git a/periodictable/crystal_structure.py b/periodictable/crystal_structure.py index f076ef3..a701fc6 100644 --- a/periodictable/crystal_structure.py +++ b/periodictable/crystal_structure.py @@ -40,9 +40,10 @@ This data is from Ashcroft and Mermin. ''' +from typing import Any from .core import PeriodicTable -crystal_structures = [ +crystal_structures: list[dict[str, Any]|None] = [ {'symmetry': 'diatom', 'd': 0.74}, #H {'symmetry': 'atom'}, #He {'symmetry': 'BCC', 'a': 3.49}, #Li diff --git a/periodictable/density.py b/periodictable/density.py index 9ad69f7..8318b6b 100644 --- a/periodictable/density.py +++ b/periodictable/density.py @@ -143,18 +143,18 @@ def init(table: PeriodicTable, reload: bool=False) -> None: return table.properties.append('density') Isotope.density \ - = property(density, "density using inter-atomic spacing from naturally occurring form") + = property(density, doc="density using inter-atomic spacing from naturally occurring form") # type: ignore[assignment] Element.density \ - = property(density, "density using inter-atomic spacing from naturally occurring form") + = property(density, doc="density using inter-atomic spacing from naturally occurring form") # type: ignore[assignment] Element.density_units = "g/cm^3" Element.interatomic_distance \ = property(interatomic_distance, - "interatomic distance estimated from density") + doc="interatomic distance estimated from density") # type: ignore[assignment] Element.interatomic_distance_units = "angstrom" Element.number_density \ = property(number_density, - "number density estimated from mass and density") + doc="number density estimated from mass and density") # type: ignore[assignment] Element.number_density_units = "1/cm^3" for k, v in element_densities.items(): diff --git a/periodictable/fasta.py b/periodictable/fasta.py index 43365d4..b378477 100644 --- a/periodictable/fasta.py +++ b/periodictable/fasta.py @@ -73,7 +73,7 @@ from pathlib import Path # Warning: name clash with Sequence from collections.abc import Iterator -from typing import IO +from typing import IO, cast from .formulas import formula as parse_formula, Formula, FormulaInput from .nsf import neutron_sld @@ -82,7 +82,7 @@ from .constants import avogadro_number # CRUFT 1.5.2: retaining fasta.isotope_substitution for compatibility -def isotope_substitution(formula: Formula, source: Atom, target: Atom, portion: float=1): +def isotope_substitution(formula: Formula, source: Atom, target: Atom, portion: float=1.0): """ Substitute one atom/isotope in a formula with another in some proportion. @@ -188,6 +188,7 @@ def __init__( M.density = 1e24*M.molecular_mass/cell_volume if cell_volume > 0 else 0 #print name, M.molecular_mass, cell_volume, M.density else: + assert M.density is not None, "Need density to compute cell volume" cell_volume = 1e24*M.molecular_mass/M.density H = M.replace(elements.H[1], elements.H) @@ -590,9 +591,9 @@ def fasta_table() -> None: for v in rows: protons = sum(num*el.number for el, num in v.natural_formula.atoms.items()) electrons = protons - v.charge - Xsld = xray_sld(v.formula, wavelength=elements.Cu.K_alpha) + Xsld = xray_sld(v.formula, wavelength=cast(float, elements.Cu.K_alpha)) print("%25s %7.1f %7.1f %7.1f %5.2f %5d %5.2f %5.2f %5.2f %5.1f"%( - v.name, v.mass, v.Dmass, v.cell_volume, v.natural_formula.density, + v.name, v.mass, v.Dmass, v.cell_volume, v.natural_formula.density or 0., electrons, Xsld[0], v.sld, v.Dsld, v.D2Omatch)) beta_casein = "RELEELNVPGEIVESLSSSEESITRINKKIEKFQSEEQQQTEDELQDKIHPFAQTQSLVYPFPGPIPNSLPQNIPPLTQTPVVVPPFLQPEVMGVSKVKEAMAPKHKEMPFPKYPVEPFTESQSLTLTDVENLHLPLPLLQSWMHQPHQPLPPTVMFPPQSVLSLSQSKVLPVPQKAVPYPQRDMPIQAFLLYQEPVLGPVRGPFPIIV" diff --git a/periodictable/formulas.py b/periodictable/formulas.py index 561db4c..eb3ffcf 100644 --- a/periodictable/formulas.py +++ b/periodictable/formulas.py @@ -108,7 +108,7 @@ def _mix_by_weight_pairs(pairs: list[tuple["Formula", float]]) -> "Formula": result += ((q/f.mass)/scale) * f if all(f.density for f, _ in pairs): # Tested that densities are not None, so the following will work - volume = sum(q/f.density for f, q in pairs)/scale + volume = sum(q/cast(float, f.density) for f, q in pairs)/scale result.density = result.mass/volume return result @@ -193,9 +193,9 @@ def _mix_by_volume_pairs(pairs: list[tuple["Formula", float]]) -> "Formula": # = q / (mass/density) # = q * density / mass # scale this so that n = 1 for the smallest quantity - scale = min(q*f.density/f.mass for f, q in pairs) + scale = min(q*cast(float, f.density)/f.mass for f, q in pairs) for f, q in pairs: - result += ((q*f.density/f.mass)/scale) * f + result += ((q*cast(float, f.density)/f.mass)/scale) * f volume = sum(q for _, q in pairs)/scale result.density = result.mass/volume @@ -298,7 +298,7 @@ def formula( structure = ((1, cast(Atom, compound)), ) elif isinstance(compound, dict): structure = _convert_to_hill_notation(cast(dict[Atom, float], compound)) - elif _is_string_like(compound): + elif isinstance(compound, str): try: chem = parse_formula(compound, table=table) if name: @@ -403,7 +403,7 @@ def natural_mass_ratio(self) -> float: return (total_natural_mass + charge_correction)/(total_isotope_mass + charge_correction) @property - def natural_density(self) -> float: + def natural_density(self) -> float | None: """ |g/cm^3| @@ -411,6 +411,8 @@ def natural_density(self) -> float: replaced by the naturally occurring abundance of the element without changing the cell volume. """ + if self.density is None: + return None return self.density*self.natural_mass_ratio() @natural_density.setter @@ -455,7 +457,7 @@ def mass_fraction(self) -> dict[Atom, float]: return dict((a, m*a.mass/total_mass) for a, m in self.atoms.items()) # TODO: Remove compound._pf. It is unused and wrong. - def _pf(self) -> float: + def _pf(self) -> float | None: """ packing factor | unitless @@ -531,6 +533,7 @@ def volume(self, *args, **kw) -> float: # Compute atomic volume V = 0. for atom, count in self.atoms.items(): + assert atom.covalent_radius is not None, "Need covalent radius for "+str(atom) radius = atom.covalent_radius #if el.number == 1 and H_radius is not None: # radius = H_radius @@ -607,7 +610,7 @@ def change_table(self, table: PeriodicTable) -> "Formula": self.structure = _change_table(self.structure, table) return self - def replace(self, source, target, portion=1): + def replace(self, source, target, portion=1.0): """ Create a new formula with one atom/isotope substituted for another. @@ -688,6 +691,7 @@ def _isotope_substitution(compound: "Formula", source: Atom, target: Atom, porti """ # TODO: fails if density is not defined atoms = compound.atoms + assert compound.density is not None, "Need density for isotope substitution" if source in atoms: mass = compound.mass mass_reduction = atoms[source]*portion*(source.mass - target.mass) @@ -1099,13 +1103,6 @@ def _str_atoms(seq) -> str: return ret -def _is_string_like(val: Any) -> bool: - """Returns True if val acts like a string""" - try: - val+'' - except Exception: - return False - return True def from_subscript(value: str) -> str: """ diff --git a/periodictable/mass.py b/periodictable/mass.py index 1ad7b73..dbc640a 100644 --- a/periodictable/mass.py +++ b/periodictable/mass.py @@ -61,6 +61,7 @@ materials (IUPAC Technical Report). Pure and Applied Chemistry, 93(1), 155-166. https://doi.org/10.1515/pac-2018-0916 """ +from typing import cast from .core import Element, Isotope, PeriodicTable, default_table from .util import parse_uncertainty @@ -94,10 +95,10 @@ def init(table: PeriodicTable, reload: bool=False) -> None: if 'mass' in table.properties and not reload: return table.properties.append('mass') - Element.mass = property(mass, doc=mass.__doc__) + Element.mass = property(mass, doc=mass.__doc__) # type: ignore[assignment] Element.mass_units = "u" - Isotope.mass = property(mass, doc=mass.__doc__) - Isotope.abundance = property(abundance, doc=abundance.__doc__) + Isotope.mass = property(mass, doc=mass.__doc__) # type: ignore[assignment] + Isotope.abundance = property(abundance, doc=abundance.__doc__) # type: ignore[assignment] Isotope.abundance_units = "%" # Parse isotope mass table where each line looks like: @@ -113,9 +114,9 @@ def init(table: PeriodicTable, reload: bool=False) -> None: iso = el.add_isotope(int(astr)) # Note: new mass table doesn't include nominal values for transuranics # so use old masses here and override later with new masses. - el._mass, el._mass_unc = parse_uncertainty(el_mass) + el._mass, el._mass_unc = cast(tuple[float, float], parse_uncertainty(el_mass)) #el._mass, el._mass_unc = None, None - iso._mass, iso._mass_unc = parse_uncertainty(iso_mass) + iso._mass, iso._mass_unc = cast(tuple[float, float], parse_uncertainty(iso_mass)) #iso._abundance, iso._abundance_unc = parse_uncertainty(p) iso._abundance, iso._abundance_unc = 0, 0 @@ -139,7 +140,7 @@ def init(table: PeriodicTable, reload: bool=False) -> None: #from uncertainties import ufloat as U #if delta > 0.01: # print(f"{el.number}-{el.symbol} mass changed by {delta:.2f}% to {U(v,dv):fS} from {U(el._mass,el._mass_unc):fS}") - el._mass, el._mass_unc = parse_uncertainty(valstr) + el._mass, el._mass_unc = cast(tuple[float, float], parse_uncertainty(valstr)) #Li_ratio = table.Li[7]._abundance/table.Li[6]._abundance @@ -176,25 +177,25 @@ def init(table: PeriodicTable, reload: bool=False) -> None: #print(line) parts = line.strip().split() #print(parts) - value[int(parts[0])] = parse_uncertainty(parts[1]) + value[int(parts[0])] = cast(tuple[float, float], parse_uncertainty(parts[1])) #new_Li_ratio = table.Li[7]._abundance/table.Li[6]._abundance #print(f"Li6:Li7 ratio changed from {Li_ratio:.1f} to {new_Li_ratio:.1f}") def print_natural_mass(table: PeriodicTable|None=None) -> None: - from uncertainties import ufloat as U + from uncertainties import ufloat as U # type: ignore[import-untyped] table = default_table(table) for el in table: iso_mass = [ - U(iso.abundance, iso._abundance_unc)/100*U(iso.mass, iso._mass_unc) + U(iso.abundance, iso._abundance_unc)/100*U(iso.mass, iso._mass_unc) # type: ignore[operator] for iso in el if iso.abundance>0] if iso_mass: el_mass = U(el.mass, el._mass_unc) iso_sum = sum(iso_mass) - delta = el_mass - iso_sum + delta = el_mass - iso_sum # type: ignore[operator] # python 3.6 and above only - if abs(delta.n) > 1e-3 or delta.s/iso_sum.n > 0.01: + if abs(delta.n) > 1e-3 or delta.s/iso_sum.n > 0.01: # type: ignore[operator,union-attr] print(f"{el.number}-{el}: {el_mass:fS}, sum: {iso_sum:fS}, Δ={delta:fS}") #print(f"{el.number}-{el}: {delta:fS}") #print(f"{el.number}-{el}: {el_mass:fS}, sum: {iso_sum:fS}, Δ=") diff --git a/periodictable/mass_2001.py b/periodictable/mass_2001.py index 48d8227..395d9e7 100644 --- a/periodictable/mass_2001.py +++ b/periodictable/mass_2001.py @@ -55,6 +55,7 @@ and High-Energy Physics, Amsterdam, The Netherlands. """ +from typing import cast from .core import Element, Isotope, PeriodicTable from .mass import mass, abundance from .util import parse_uncertainty @@ -64,10 +65,10 @@ def init(table: PeriodicTable, reload: bool=False) -> None: if 'mass' in table.properties and not reload: return table.properties.append('mass') - Element.mass = property(mass, doc=mass.__doc__) + Element.mass = property(mass, doc=mass.__doc__) # type: ignore Element.mass_units = "u" - Isotope.mass = property(mass, doc=mass.__doc__) - Isotope.abundance = property(abundance, doc=abundance.__doc__) + Isotope.mass = property(mass, doc=mass.__doc__) # type: ignore + Isotope.abundance = property(abundance, doc=abundance.__doc__) # type: ignore Isotope.abundance_units = "%" for line in massdata.split('\n'): @@ -77,9 +78,9 @@ def init(table: PeriodicTable, reload: bool=False) -> None: assert el.symbol == sym, \ "Symbol %s does not match %s"%(sym, el.symbol) iso = el.add_isotope(int(a)) - el._mass, el._mass_unc = parse_uncertainty(avg) - iso._mass, iso._mass_unc = parse_uncertainty(m) - iso._abundance,iso._abundance_unc = parse_uncertainty(p) if p else (0,0) + el._mass, el._mass_unc = cast(tuple[float, float], parse_uncertainty(avg)) + iso._mass, iso._mass_unc = cast(tuple[float, float], parse_uncertainty(m)) + iso._abundance, iso._abundance_unc = cast(tuple[float, float], parse_uncertainty(p)) if p else (0, 0) # # A single neutron is an isotope of element 0 # from .constants import neutron_mass diff --git a/periodictable/nsf.py b/periodictable/nsf.py index 68b0f89..4d404ec 100644 --- a/periodictable/nsf.py +++ b/periodictable/nsf.py @@ -464,7 +464,7 @@ def has_sld(self) -> bool: return self.b_c is not None and self._number_density is not None # PAK 2021-04-05: allow energy dependent b_c - def scattering_by_wavelength(self, wavelength: ArrayLike) -> tuple[ArrayLike, ArrayLike]|tuple[None, None]: + def scattering_by_wavelength(self, wavelength: float | NDArray) -> tuple[float | NDArray, float | NDArray]|tuple[None, None]: r""" Return scattering length and total cross section for each wavelength. @@ -494,10 +494,10 @@ def scattering_by_wavelength(self, wavelength: ArrayLike) -> tuple[ArrayLike, Ar #b_c = np.interp(energy, self.nsf_table[0], self.nsf_table[1]) b_c = np.interp(wavelength, self.nsf_table[0], self.nsf_table[1]) # TODO: sigma_s should include an incoherent contribution - sigma_s = _4PI_100*abs(b_c)**2 # 1 barn = 1 fm^2 1e-2 barn/fm^2 + sigma_s = _4PI_100*np.abs(b_c)**2 # 1 barn = 1 fm^2 1e-2 barn/fm^2 return b_c, sigma_s - def sld(self, *, wavelength: ArrayLike=ABSORPTION_WAVELENGTH) -> NDArray|None: + def sld(self, *, wavelength: float | NDArray =ABSORPTION_WAVELENGTH) -> NDArray|None: r""" Returns scattering length density for the element at natural abundance and density. @@ -518,7 +518,7 @@ def sld(self, *, wavelength: ArrayLike=ABSORPTION_WAVELENGTH) -> NDArray|None: return None return self.scattering(wavelength=wavelength)[0] - def scattering(self, *, wavelength: ArrayLike=ABSORPTION_WAVELENGTH) -> tuple[NDArray, NDArray, NDArray]|tuple[None, None, None]: + def scattering(self, *, wavelength: float | NDArray=ABSORPTION_WAVELENGTH) -> tuple[NDArray, NDArray, NDArray]|tuple[None, None, None]: r""" Returns neutron scattering information for the element at natural abundance and density. @@ -567,8 +567,8 @@ def energy_dependent_init(table: PeriodicTable) -> None: # Lu nat missing from Lynn and Seeger, so mix Lu[175] and Lu[176] Lu175 = table.Lu[175] Lu176 = table.Lu[176] - bc_175 = Lu175.neutron.b_c_complex - wavelength, bc_176 = Lu176.neutron.nsf_table + bc_175 = cast(complex, Lu175.neutron.b_c_complex) + wavelength, bc_176 = cast(tuple[DoubleArray, DoubleArray], Lu176.neutron.nsf_table) bc_nat = (bc_175*Lu175.abundance + bc_176*Lu176.abundance)/100.0 # 1 fm = 1fm * %/100 table.Lu.neutron.nsf_table = wavelength, cast(DoubleArray, bc_nat) #table.Lu.neutron.total = 0. # zap total cross section @@ -680,7 +680,7 @@ def init(table: PeriodicTable, reload: bool=False) -> None: def neutron_scattering( compound: "FormulaInput", *, density: float|None=None, - wavelength: ArrayLike|None=None, + wavelength: float|NDArray|None=None, energy: ArrayLike|None=None, natural_density: float|None=None, table: PeriodicTable|None=None, @@ -940,7 +940,8 @@ def neutron_scattering( # Sum over the quantities (note: formulas can have fractional atoms) molar_mass, num_atoms = 0., 0. - b_c, sigma_s = 0., 0. + b_c: float|NDArray = 0. + sigma_s: float|NDArray = 0. is_energy_dependent = False for element, quantity in compound.atoms.items(): # TODO: use NaN rather than None @@ -949,7 +950,7 @@ def neutron_scattering( molar_mass += element.mass*quantity num_atoms += quantity # PAK 2021-04-05: allow energy dependent b_c, b'' - b_ck, sigma_sk = element.neutron.scattering_by_wavelength(wavelength) + b_ck, sigma_sk = cast(tuple[float|NDArray, float|NDArray], element.neutron.scattering_by_wavelength(wavelength)) #print(f"{element=}; {b_ck=}; {sigma_sk=}") b_c += quantity * b_ck sigma_s += quantity * sigma_sk @@ -958,7 +959,7 @@ def neutron_scattering( # If nothing to sum, return values for a vacuum. This might be because # the material has no atoms or it might be because the density is zero. if molar_mass*compound.density == 0: - return (0, 0, 0), (0, 0, 0), inf + return (0, 0, 0), (0, 0, 0), inf # type: ignore[return-value] # Turn weighted sums into scattering factors b_c /= num_atoms diff --git a/periodictable/py.typed b/periodictable/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index b647cfa..36ec19b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,15 +22,20 @@ "Topic :: Scientific/Engineering :: Chemistry", "Topic :: Scientific/Engineering :: Physics", ] - requires-python = ">=3.8" + requires-python = ">=3.10" -[project.optional-dependencies] +[dependency-groups] + build = ["build"] + # Matplotlib and uncertainties are optional packages, used for making + # plots in the docs and generating neutron data tables for the web. + # mypy checks all code so they are needed for testing as well. + optional = ["uncertainties", "matplotlib"] + docs = ["sphinx", {include-group = "optional"}] + test = ["pytest", "pytest-cov", "pytest-mypy", {include-group = "optional"}] dev = [ - "build", - "pytest", - "pytest-cov", - "matplotlib", - "sphinx", + {include-group = "build"}, + {include-group = "docs"}, + {include-group = "test"}, ] [project.urls] @@ -48,10 +53,17 @@ packages = ["periodictable"] [tool.pytest.ini_options] - addopts = ["--doctest-modules", "--doctest-glob=*.rst", "--cov=periodictable"] + # mypy doesn't work on the wheel tests, so these options are repeated in + # .github/workflows/test.yml. We could use pytest-enabler with "pytest -p no:mypy" + # but that introduces yet another dependency. + addopts = ["--doctest-modules", "--doctest-glob=*.rst", "--cov=periodictable", "--mypy"] doctest_optionflags = "ELLIPSIS" pythonpath = ["doc/sphinx"] testpaths = ["periodictable", "test", "doc/sphinx/guide"] python_files = "*.py" python_classes = "NoClassTestsWillMatch" python_functions = ["test", "*_test", "test_*"] + +[tool.mypy] +#files = ["test/test_element_types.py"] +follow_imports = "silent" # Check imports but don't report errors in them diff --git a/test/test_element_types.py b/test/test_element_types.py new file mode 100644 index 0000000..24140b8 --- /dev/null +++ b/test/test_element_types.py @@ -0,0 +1,31 @@ +import periodictable +from periodictable.core import Element, Isotope + +# Test symbol access +c_element: Element = periodictable.C +fe_element: Element = periodictable.Fe +u_element: Element = periodictable.U + +# Test name access +carbon: Element = periodictable.carbon +iron: Element = periodictable.iron +uranium: Element = periodictable.uranium + +# Test that we can access element properties +mass: float = periodictable.C.mass +symbol: str = periodictable.Fe.symbol +number: int = periodictable.U.number + +# Test special cases +deuterium: Isotope = periodictable.D +tritium: Isotope = periodictable.T +neutron: Element = periodictable.n + +# Test isotopes have isotope attribute +D_iso: int = periodictable.D.isotope +Dion_iso: int = periodictable.D.ion[1].isotope +Fe56_iso: int = periodictable.Fe[56].isotope + +# Test that elements do not +assert not hasattr(periodictable.H, "isotope") +assert not hasattr(periodictable.H.ion[1], "isotope")